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

@@ -1183,7 +1183,7 @@ async function runTests() {
assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks');
})) passed++; else failed++;
if (test('all hook commands use node or are skill shell scripts', () => {
if (test('all hook commands use node or approved shell wrappers', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
@@ -1196,9 +1196,11 @@ async function runTests() {
/^(bash|sh)\s/.test(hook.command) ||
hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/')
);
const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command);
const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js');
assert.ok(
isNode || isSkillScript,
`Hook command should start with 'node' or be a skill shell script: ${hook.command.substring(0, 80)}...`
isNode || isSkillScript || isHookShellWrapper || isSessionStartFallback,
`Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...`
);
}
}
@@ -1210,7 +1212,7 @@ async function runTests() {
}
})) passed++; else failed++;
if (test('script references use CLAUDE_PLUGIN_ROOT variable', () => {
if (test('script references use CLAUDE_PLUGIN_ROOT variable (except SessionStart fallback)', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
@@ -1219,7 +1221,8 @@ async function runTests() {
for (const hook of entry.hooks) {
if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) {
// Check for the literal string "${CLAUDE_PLUGIN_ROOT}" in the command
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}');
const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js');
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartFallback;
assert.ok(
hasPluginRoot,
`Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`

View File

@@ -11,6 +11,7 @@ const path = require('path');
const fs = require('fs');
const os = require('os');
const { spawn } = require('child_process');
const REPO_ROOT = path.join(__dirname, '..', '..');
// Test helper
function _test(name, fn) {
@@ -90,22 +91,20 @@ function runHookWithInput(scriptPath, input = {}, env = {}, timeoutMs = 10000) {
}
/**
* Run an inline hook command (like those in hooks.json)
* @param {string} command - The node -e "..." command
* Run a hook command string exactly as declared in hooks.json.
* Supports wrapped node script commands and shell wrappers.
* @param {string} command - Hook command from hooks.json
* @param {object} input - Hook input object
* @param {object} env - Environment variables
*/
function _runInlineHook(command, input = {}, env = {}, timeoutMs = 10000) {
function runHookCommand(command, input = {}, env = {}, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
// Extract the code from node -e "..."
const match = command.match(/^node -e "(.+)"$/s);
if (!match) {
reject(new Error('Invalid inline hook command format'));
return;
}
const isWindows = process.platform === 'win32';
const shell = isWindows ? 'cmd' : 'bash';
const shellArgs = isWindows ? ['/d', '/s', '/c', command] : ['-lc', command];
const proc = spawn('node', ['-e', match[1]], {
env: { ...process.env, ...env },
const proc = spawn(shell, shellArgs, {
env: { ...process.env, CLAUDE_PLUGIN_ROOT: REPO_ROOT, ...env },
stdio: ['pipe', 'pipe', 'pipe']
});
@@ -116,9 +115,9 @@ function _runInlineHook(command, input = {}, env = {}, timeoutMs = 10000) {
proc.stdout.on('data', data => stdout += data);
proc.stderr.on('data', data => stderr += data);
// Ignore EPIPE errors (process may exit before we finish writing)
// Ignore EPIPE/EOF errors (process may exit before we finish writing)
proc.stdin.on('error', (err) => {
if (err.code !== 'EPIPE') {
if (err.code !== 'EPIPE' && err.code !== 'EOF') {
if (timer) clearTimeout(timer);
reject(err);
}
@@ -130,8 +129,8 @@ function _runInlineHook(command, input = {}, env = {}, timeoutMs = 10000) {
proc.stdin.end();
timer = setTimeout(() => {
proc.kill('SIGKILL');
reject(new Error(`Inline hook timed out after ${timeoutMs}ms`));
proc.kill(isWindows ? undefined : 'SIGKILL');
reject(new Error(`Hook command timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.on('close', code => {
@@ -239,35 +238,16 @@ async function runTests() {
if (await asyncTest('blocking hooks output BLOCKED message', async () => {
// Test the dev server blocking hook — must send a matching command
const blockingCommand = hooks.hooks.PreToolUse[0].hooks[0].command;
const match = blockingCommand.match(/^node -e "(.+)"$/s);
const proc = spawn('node', ['-e', match[1]], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stderr = '';
let code = null;
proc.stderr.on('data', data => stderr += data);
// Send a dev server command so the hook triggers the block
proc.stdin.write(JSON.stringify({
const result = await runHookCommand(blockingCommand, {
tool_input: { command: 'npm run dev' }
}));
proc.stdin.end();
await new Promise(resolve => {
proc.on('close', (c) => {
code = c;
resolve();
});
});
// Hook only blocks on non-Windows platforms (tmux is Unix-only)
if (process.platform === 'win32') {
assert.strictEqual(code, 0, 'On Windows, hook should not block (exit 0)');
assert.strictEqual(result.code, 0, 'On Windows, hook should not block (exit 0)');
} else {
assert.ok(stderr.includes('BLOCKED'), 'Blocking hook should output BLOCKED');
assert.strictEqual(code, 2, 'Blocking hook should exit with code 2');
assert.ok(result.stderr.includes('BLOCKED'), 'Blocking hook should output BLOCKED');
assert.strictEqual(result.code, 2, 'Blocking hook should exit with code 2');
}
})) passed++; else failed++;
@@ -284,30 +264,15 @@ async function runTests() {
if (await asyncTest('blocking hooks exit with code 2', async () => {
// The dev server blocker blocks when a dev server command is detected
const blockingCommand = hooks.hooks.PreToolUse[0].hooks[0].command;
const match = blockingCommand.match(/^node -e "(.+)"$/s);
const proc = spawn('node', ['-e', match[1]], {
stdio: ['pipe', 'pipe', 'pipe']
});
let code = null;
proc.stdin.write(JSON.stringify({
const result = await runHookCommand(blockingCommand, {
tool_input: { command: 'yarn dev' }
}));
proc.stdin.end();
await new Promise(resolve => {
proc.on('close', (c) => {
code = c;
resolve();
});
});
// Hook only blocks on non-Windows platforms (tmux is Unix-only)
if (process.platform === 'win32') {
assert.strictEqual(code, 0, 'On Windows, hook should not block (exit 0)');
assert.strictEqual(result.code, 0, 'On Windows, hook should not block (exit 0)');
} else {
assert.strictEqual(code, 2, 'Blocking hook should exit 2');
assert.strictEqual(result.code, 2, 'Blocking hook should exit 2');
}
})) passed++; else failed++;
@@ -391,26 +356,13 @@ async function runTests() {
assert.ok(prHook, 'PR hook should exist');
const match = prHook.hooks[0].command.match(/^node -e "(.+)"$/s);
const proc = spawn('node', ['-e', match[1]], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stderr = '';
proc.stderr.on('data', data => stderr += data);
// Simulate gh pr create output
proc.stdin.write(JSON.stringify({
const result = await runHookCommand(prHook.hooks[0].command, {
tool_input: { command: 'gh pr create --title "Test"' },
tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' }
}));
proc.stdin.end();
await new Promise(resolve => proc.on('close', resolve));
});
assert.ok(
stderr.includes('PR created') || stderr.includes('github.com'),
result.stderr.includes('PR created') || result.stderr.includes('github.com'),
'Should extract and log PR URL'
);
})) passed++; else failed++;
@@ -678,8 +630,18 @@ async function runTests() {
assert.strictEqual(typeof asyncHook.hooks[0].timeout, 'number', 'Timeout should be a number');
assert.ok(asyncHook.hooks[0].timeout > 0, 'Timeout should be positive');
const match = asyncHook.hooks[0].command.match(/^node -e "(.+)"$/s);
assert.ok(match, 'Async hook command should be node -e format');
const command = asyncHook.hooks[0].command;
const isNodeInline = command.startsWith('node -e');
const isNodeScript = command.startsWith('node "');
const isShellWrapper =
command.startsWith('bash "') ||
command.startsWith('sh "') ||
command.startsWith('bash -lc ') ||
command.startsWith('sh -c ');
assert.ok(
isNodeInline || isNodeScript || isShellWrapper,
`Async hook command should be runnable (node -e, node script, or shell wrapper), got: ${command.substring(0, 80)}`
);
})) passed++; else failed++;
if (await asyncTest('all hook commands in hooks.json are valid format', async () => {
@@ -692,11 +654,16 @@ async function runTests() {
const isInline = hook.command.startsWith('node -e');
const isFilePath = hook.command.startsWith('node "');
const isShellScript = hook.command.endsWith('.sh');
const isShellWrapper =
hook.command.startsWith('bash "') ||
hook.command.startsWith('sh "') ||
hook.command.startsWith('bash -lc ') ||
hook.command.startsWith('sh -c ');
const isShellScriptPath = hook.command.endsWith('.sh');
assert.ok(
isInline || isFilePath || isShellScript,
`Hook command in ${hookType} should be inline (node -e), file path (node "), or shell script (.sh), got: ${hook.command.substring(0, 80)}`
isInline || isFilePath || isShellWrapper || isShellScriptPath,
`Hook command in ${hookType} should be node -e, node script, or shell wrapper/script, got: ${hook.command.substring(0, 80)}`
);
}
}

View File

@@ -583,9 +583,10 @@ src/main.ts
assert.strictEqual(result, null, 'Uppercase letters should be rejected');
})) passed++; else failed++;
if (test('rejects filenames with extra segments', () => {
if (test('accepts hyphenated short IDs (extra segments)', () => {
const result = sessionManager.parseSessionFilename('2026-02-01-abc12345-extra-session.tmp');
assert.strictEqual(result, null, 'Extra segments should be rejected');
assert.ok(result, 'Hyphenated short IDs should be accepted');
assert.strictEqual(result.shortId, 'abc12345-extra');
})) passed++; else failed++;
if (test('rejects impossible month (13)', () => {

View File

@@ -21,7 +21,12 @@ const {
buildPrompt,
askClaude,
isValidSessionName,
handleClear
handleClear,
getSessionMetrics,
searchSessions,
branchSession,
exportSession,
compactSession
} = require(path.join(__dirname, '..', '..', 'scripts', 'claw.js'));
// Test helper — matches ECC's custom test pattern
@@ -229,6 +234,92 @@ function runTests() {
assert.strictEqual(isValidSessionName(undefined), false);
})) passed++; else failed++;
console.log('\nNanoClaw v2:');
if (test('getSessionMetrics returns non-zero token estimate for populated history', () => {
const tmpDir = makeTmpDir();
const filePath = path.join(tmpDir, 'metrics.md');
try {
appendTurn(filePath, 'User', 'Implement auth');
appendTurn(filePath, 'Assistant', 'Working on it');
const metrics = getSessionMetrics(filePath);
assert.strictEqual(metrics.turns, 2);
assert.ok(metrics.tokenEstimate > 0);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('searchSessions finds query in saved session', () => {
const tmpDir = makeTmpDir();
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const sessionPath = path.join(tmpDir, '.claude', 'claw', 'alpha.md');
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
appendTurn(sessionPath, 'User', 'Need oauth migration');
const results = searchSessions('oauth');
assert.strictEqual(results.length, 1);
assert.strictEqual(results[0].session, 'alpha');
} finally {
process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('branchSession copies history into new branch session', () => {
const tmpDir = makeTmpDir();
const originalHome = process.env.HOME;
process.env.HOME = tmpDir;
try {
const source = path.join(tmpDir, '.claude', 'claw', 'base.md');
fs.mkdirSync(path.dirname(source), { recursive: true });
appendTurn(source, 'User', 'base content');
const result = branchSession(source, 'feature-branch');
assert.strictEqual(result.ok, true);
const branched = fs.readFileSync(result.path, 'utf8');
assert.ok(branched.includes('base content'));
} finally {
process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('exportSession writes JSON export', () => {
const tmpDir = makeTmpDir();
const filePath = path.join(tmpDir, 'export.md');
const outPath = path.join(tmpDir, 'export.json');
try {
appendTurn(filePath, 'User', 'hello');
appendTurn(filePath, 'Assistant', 'world');
const result = exportSession(filePath, 'json', outPath);
assert.strictEqual(result.ok, true);
const exported = JSON.parse(fs.readFileSync(outPath, 'utf8'));
assert.strictEqual(Array.isArray(exported.turns), true);
assert.strictEqual(exported.turns.length, 2);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('compactSession reduces long histories', () => {
const tmpDir = makeTmpDir();
const filePath = path.join(tmpDir, 'compact.md');
try {
for (let i = 0; i < 30; i++) {
appendTurn(filePath, i % 2 ? 'Assistant' : 'User', `turn-${i}`);
}
const changed = compactSession(filePath, 10);
assert.strictEqual(changed, true);
const content = fs.readFileSync(filePath, 'utf8');
assert.ok(content.includes('NanoClaw Compaction'));
assert.ok(!content.includes('turn-0'));
assert.ok(content.includes('turn-29'));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
})) passed++; else failed++;
// ── Summary ───────────────────────────────────────────────────────────
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);