fix: formatting and async test bug in hooks-config

- Fix Prettier formatting in ralph-tracker.ts and respawn-controller.ts
  (whitespace drift from Phase 2/4 refactoring)
- Add missing `await` to writeHooksConfig() calls in hooks-config.test.ts
  (async function was called without await, causing ENOENT race condition)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 17:08:41 +01:00
co-authored by Claude Opus 4.6
parent 8e5f95d6ea
commit bdaa43bc5c
3 changed files with 82 additions and 91 deletions
+7 -1
View File
@@ -33,7 +33,13 @@ import {
PlanTaskStatus, PlanTaskStatus,
TddPhase, TddPhase,
} from './types.js'; } from './types.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity, Debouncer } from './utils/index.js'; import {
ANSI_ESCAPE_PATTERN_SIMPLE,
fuzzyPhraseMatch,
todoContentHash,
stringSimilarity,
Debouncer,
} from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
import { RalphPlanTracker } from './ralph-plan-tracker.js'; import { RalphPlanTracker } from './ralph-plan-tracker.js';
+59 -74
View File
@@ -1092,22 +1092,26 @@ export class RespawnController extends EventEmitter {
this.stopDetectionUpdates(); this.stopDetectionUpdates();
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.lastEmittedDetectionKey = ''; this.lastEmittedDetectionKey = '';
const id = this.cleanup.setInterval(() => { const id = this.cleanup.setInterval(
try { () => {
if (this._state !== 'stopped') { try {
const status = this.getDetectionStatus(); if (this._state !== 'stopped') {
// Only emit when status meaningfully changed (confidence, state text, or timer values) const status = this.getDetectionStatus();
// to avoid broadcasting identical data every 2s for stable/idle sessions. // Only emit when status meaningfully changed (confidence, state text, or timer values)
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`; // to avoid broadcasting identical data every 2s for stable/idle sessions.
if (key !== this.lastEmittedDetectionKey) { const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
this.lastEmittedDetectionKey = key; if (key !== this.lastEmittedDetectionKey) {
this.emit('detectionUpdate', status); this.lastEmittedDetectionKey = key;
this.emit('detectionUpdate', status);
}
} }
} catch (err) {
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
} }
} catch (err) { },
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err); 2000,
} { description: 'detection-update' }
}, 2000, { description: 'detection-update' }); );
this.timerIds.set('detection-update', id); this.timerIds.set('detection-update', id);
} }
@@ -1404,8 +1408,7 @@ export class RespawnController extends EventEmitter {
this.lastWorkingPatternTime = now; this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running // Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm','working patterns detected'); this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
// Cancel any pending completion confirmation // Cancel any pending completion confirmation
this.cancelCompletionConfirm(); this.cancelCompletionConfirm();
@@ -1537,7 +1540,7 @@ export class RespawnController extends EventEmitter {
*/ */
private checkClearComplete(): void { private checkClearComplete(): void {
// Clear the fallback timer since we got prompt detection // Clear the fallback timer since we got prompt detection
this.cancelTrackedTimer('clear-fallback','prompt detected'); this.cancelTrackedTimer('clear-fallback', 'prompt detected');
this.logAction('step', '/clear completed'); this.logAction('step', '/clear completed');
this.emit('stepCompleted', 'clear'); this.emit('stepCompleted', 'clear');
@@ -1590,7 +1593,6 @@ export class RespawnController extends EventEmitter {
'init-monitor', 'init-monitor',
3000, 3000,
() => { () => {
// If still in monitoring state and no work detected, consider it idle // If still in monitoring state and no work detected, consider it idle
if (this._state === 'monitoring_init' && !this.workingDetected) { if (this._state === 'monitoring_init' && !this.workingDetected) {
this.checkMonitoringInitIdle(); this.checkMonitoringInitIdle();
@@ -1625,7 +1627,6 @@ export class RespawnController extends EventEmitter {
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!; const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
@@ -1677,13 +1678,17 @@ export class RespawnController extends EventEmitter {
// Check interval for stuck state // Check interval for stuck state
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
const id = this.cleanup.setInterval(() => { const id = this.cleanup.setInterval(
try { () => {
this.checkStuckState(); try {
} catch (err) { this.checkStuckState();
console.error(`[RespawnController] Error in stuckStateTimer:`, err); } catch (err) {
} console.error(`[RespawnController] Error in stuckStateTimer:`, err);
}, checkIntervalMs, { description: 'stuck-state' }); }
},
checkIntervalMs,
{ description: 'stuck-state' }
);
this.timerIds.set('stuck-state', id); this.timerIds.set('stuck-state', id);
} }
@@ -1836,12 +1841,16 @@ export class RespawnController extends EventEmitter {
this.emit('timerStarted', { name, durationMs, endsAt, reason }); this.emit('timerStarted', { name, durationMs, endsAt, reason });
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`); this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
const id = this.cleanup.setTimeout(() => { const id = this.cleanup.setTimeout(
this.timerIds.delete(name); () => {
this.activeTimers.delete(name); this.timerIds.delete(name);
this.emit('timerCompleted', name); this.activeTimers.delete(name);
callback(); this.emit('timerCompleted', name);
}, durationMs, { description: name }); callback();
},
durationMs,
{ description: name }
);
this.timerIds.set(name, id); this.timerIds.set(name, id);
} }
@@ -1925,14 +1934,12 @@ export class RespawnController extends EventEmitter {
* (used when AI check is disabled or has too many errors). * (used when AI check is disabled or has too many errors).
*/ */
private startNoOutputTimer(): void { private startNoOutputTimer(): void {
this.cancelTrackedTimer('no-output-fallback','restarting'); this.cancelTrackedTimer('no-output-fallback', 'restarting');
this.startTrackedTimer( this.startTrackedTimer(
'no-output-fallback', 'no-output-fallback',
this.config.noOutputTimeoutMs, this.config.noOutputTimeoutMs,
() => { () => {
if (this._state === 'watching' || this._state === 'confirming_idle') { if (this._state === 'watching' || this._state === 'confirming_idle') {
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`); this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
@@ -1965,8 +1972,7 @@ export class RespawnController extends EventEmitter {
* This provides an additional path to AI check even without a completion message. * This provides an additional path to AI check even without a completion message.
*/ */
private startPreFilterTimer(): void { private startPreFilterTimer(): void {
this.cancelTrackedTimer('pre-filter','restarting'); this.cancelTrackedTimer('pre-filter', 'restarting');
// Only set up pre-filter when AI check is enabled // Only set up pre-filter when AI check is enabled
if (!this.config.aiIdleCheckEnabled) return; if (!this.config.aiIdleCheckEnabled) return;
@@ -1975,7 +1981,6 @@ export class RespawnController extends EventEmitter {
'pre-filter', 'pre-filter',
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
if (this._state === 'watching') { if (this._state === 'watching') {
const now = Date.now(); const now = Date.now();
const msSinceOutput = now - this.lastOutputTime; const msSinceOutput = now - this.lastOutputTime;
@@ -2080,18 +2085,16 @@ export class RespawnController extends EventEmitter {
if (result.verdict === 'IDLE') { if (result.verdict === 'IDLE') {
// Cancel any pending confirmation timers - AI has spoken // Cancel any pending confirmation timers - AI has spoken
this.cancelTrackedTimer('completion-confirm','AI verdict: IDLE'); this.cancelTrackedTimer('completion-confirm', 'AI verdict: IDLE');
this.cancelTrackedTimer('pre-filter','AI verdict: IDLE');
this.cancelTrackedTimer('pre-filter', 'AI verdict: IDLE');
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
this.emit('aiCheckCompleted', result); this.emit('aiCheckCompleted', result);
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
} else if (result.verdict === 'WORKING') { } else if (result.verdict === 'WORKING') {
// Cancel timers and go to cooldown // Cancel timers and go to cooldown
this.cancelTrackedTimer('completion-confirm','AI verdict: WORKING'); this.cancelTrackedTimer('completion-confirm', 'AI verdict: WORKING');
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
this.emit('aiCheckCompleted', result); this.emit('aiCheckCompleted', result);
@@ -2147,14 +2150,12 @@ export class RespawnController extends EventEmitter {
* and no elicitation dialog was detected. Only handles plan mode approvals. * and no elicitation dialog was detected. Only handles plan mode approvals.
*/ */
private startAutoAcceptTimer(): void { private startAutoAcceptTimer(): void {
this.cancelTrackedTimer('auto-accept','restarting'); this.cancelTrackedTimer('auto-accept', 'restarting');
this.startTrackedTimer( this.startTrackedTimer(
'auto-accept', 'auto-accept',
this.config.autoAcceptDelayMs, this.config.autoAcceptDelayMs,
() => { () => {
this.tryAutoAccept(); this.tryAutoAccept();
}, },
'plan mode detection' 'plan mode detection'
@@ -2166,8 +2167,7 @@ export class RespawnController extends EventEmitter {
* Called when a completion message is detected (normal idle flow handles it). * Called when a completion message is detected (normal idle flow handles it).
*/ */
private cancelAutoAcceptTimer(): void { private cancelAutoAcceptTimer(): void {
this.cancelTrackedTimer('auto-accept','cancelled'); this.cancelTrackedTimer('auto-accept', 'cancelled');
} }
/** /**
@@ -2328,7 +2328,7 @@ export class RespawnController extends EventEmitter {
} }
// Cancel completion confirmation - auto-accept takes precedence // Cancel completion confirmation - auto-accept takes precedence
this.cancelTrackedTimer('completion-confirm','auto-accept'); this.cancelTrackedTimer('completion-confirm', 'auto-accept');
this.completionMessageTime = null; this.completionMessageTime = null;
@@ -2383,12 +2383,10 @@ export class RespawnController extends EventEmitter {
} }
// Cancel completion confirm timer - hook takes precedence // Cancel completion confirm timer - hook takes precedence
this.cancelTrackedTimer('completion-confirm','Stop hook received'); this.cancelTrackedTimer('completion-confirm', 'Stop hook received');
// Cancel pre-filter timer - hook takes precedence // Cancel pre-filter timer - hook takes precedence
this.cancelTrackedTimer('pre-filter','Stop hook received'); this.cancelTrackedTimer('pre-filter', 'Stop hook received');
// Start short confirmation timer to handle race conditions // Start short confirmation timer to handle race conditions
// (e.g., Stop hook arrives but Claude immediately starts new work) // (e.g., Stop hook arrives but Claude immediately starts new work)
@@ -2422,12 +2420,11 @@ export class RespawnController extends EventEmitter {
} }
// Cancel all other detection timers - this is definitive // Cancel all other detection timers - this is definitive
this.cancelTrackedTimer('completion-confirm','idle_prompt received'); this.cancelTrackedTimer('completion-confirm', 'idle_prompt received');
this.cancelTrackedTimer('pre-filter','idle_prompt received'); this.cancelTrackedTimer('pre-filter', 'idle_prompt received');
this.cancelTrackedTimer('no-output-fallback','idle_prompt received');
this.cancelTrackedTimer('no-output-fallback', 'idle_prompt received');
// idle_prompt is an even stronger signal than Stop hook (60s+ idle) // idle_prompt is an even stronger signal than Stop hook (60s+ idle)
// Skip confirmation and go directly to idle // Skip confirmation and go directly to idle
@@ -2441,15 +2438,12 @@ export class RespawnController extends EventEmitter {
* @param hookType - Which hook triggered this ('stop' or 'idle_prompt') * @param hookType - Which hook triggered this ('stop' or 'idle_prompt')
*/ */
private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void { private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void {
this.cancelTrackedTimer('hook-confirm','restarting'); this.cancelTrackedTimer('hook-confirm', 'restarting');
this.startTrackedTimer( this.startTrackedTimer(
'hook-confirm', 'hook-confirm',
RespawnController.HOOK_CONFIRM_DELAY_MS, RespawnController.HOOK_CONFIRM_DELAY_MS,
() => { () => {
// Verify we haven't received new output since the hook arrived // Verify we haven't received new output since the hook arrived
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime; const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
if (hookTime && this.lastOutputTime > hookTime) { if (hookTime && this.lastOutputTime > hookTime) {
@@ -2522,8 +2516,7 @@ export class RespawnController extends EventEmitter {
* After completion message, waits for output silence then triggers AI check. * After completion message, waits for output silence then triggers AI check.
*/ */
private startCompletionConfirmTimer(): void { private startCompletionConfirmTimer(): void {
this.cancelTrackedTimer('completion-confirm','restarting'); this.cancelTrackedTimer('completion-confirm', 'restarting');
this.setState('confirming_idle'); this.setState('confirming_idle');
this.logAction('detection', 'Completion message found in output'); this.logAction('detection', 'Completion message found in output');
@@ -2532,7 +2525,6 @@ export class RespawnController extends EventEmitter {
'completion-confirm', 'completion-confirm',
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) { if (msSinceOutput >= this.config.completionConfirmMs) {
@@ -2553,7 +2545,7 @@ export class RespawnController extends EventEmitter {
* Cancel completion confirmation if new activity detected. * Cancel completion confirmation if new activity detected.
*/ */
private cancelCompletionConfirm(): void { private cancelCompletionConfirm(): void {
this.cancelTrackedTimer('completion-confirm','activity detected'); this.cancelTrackedTimer('completion-confirm', 'activity detected');
if (this._state === 'confirming_idle') { if (this._state === 'confirming_idle') {
this.setState('watching'); this.setState('watching');
@@ -2567,14 +2559,12 @@ export class RespawnController extends EventEmitter {
* This ensures Claude has finished processing before we send the next command. * This ensures Claude has finished processing before we send the next command.
*/ */
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void { private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
this.cancelTrackedTimer('step-confirm','restarting'); this.cancelTrackedTimer('step-confirm', 'restarting');
this.startTrackedTimer( this.startTrackedTimer(
'step-confirm', 'step-confirm',
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
@@ -2607,8 +2597,7 @@ export class RespawnController extends EventEmitter {
* Cancel step confirmation if working patterns detected. * Cancel step confirmation if working patterns detected.
*/ */
private cancelStepConfirm(): void { private cancelStepConfirm(): void {
this.cancelTrackedTimer('step-confirm','working detected'); this.cancelTrackedTimer('step-confirm', 'working detected');
} }
/** /**
@@ -2774,7 +2763,6 @@ export class RespawnController extends EventEmitter {
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
@@ -2815,7 +2803,6 @@ export class RespawnController extends EventEmitter {
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /clear'); this.logAction('command', 'Sending: /clear');
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
@@ -2828,7 +2815,6 @@ export class RespawnController extends EventEmitter {
'clear-fallback', 'clear-fallback',
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS, RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
() => { () => {
if (this._state === 'waiting_clear') { if (this._state === 'waiting_clear') {
this.logAction('step', '/clear fallback: proceeding to /init'); this.logAction('step', '/clear fallback: proceeding to /init');
this.emit('stepCompleted', 'clear'); this.emit('stepCompleted', 'clear');
@@ -2859,7 +2845,6 @@ export class RespawnController extends EventEmitter {
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /init'); this.logAction('command', 'Sending: /init');
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
+16 -16
View File
@@ -111,19 +111,19 @@ describe('writeHooksConfig', () => {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
}); });
it('should create .claude directory if it does not exist', () => { it('should create .claude directory if it does not exist', async () => {
writeHooksConfig(testDir); await writeHooksConfig(testDir);
expect(existsSync(join(testDir, '.claude'))).toBe(true); expect(existsSync(join(testDir, '.claude'))).toBe(true);
}); });
it('should create settings.local.json', () => { it('should create settings.local.json', async () => {
writeHooksConfig(testDir); await writeHooksConfig(testDir);
const settingsPath = join(testDir, '.claude', 'settings.local.json'); const settingsPath = join(testDir, '.claude', 'settings.local.json');
expect(existsSync(settingsPath)).toBe(true); expect(existsSync(settingsPath)).toBe(true);
}); });
it('should write valid JSON', () => { it('should write valid JSON', async () => {
writeHooksConfig(testDir); await writeHooksConfig(testDir);
const settingsPath = join(testDir, '.claude', 'settings.local.json'); const settingsPath = join(testDir, '.claude', 'settings.local.json');
const content = readFileSync(settingsPath, 'utf-8'); const content = readFileSync(settingsPath, 'utf-8');
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
@@ -132,8 +132,8 @@ describe('writeHooksConfig', () => {
expect(parsed.hooks).toHaveProperty('Stop'); expect(parsed.hooks).toHaveProperty('Stop');
}); });
it('should include hooks config in output', () => { it('should include hooks config in output', async () => {
writeHooksConfig(testDir); await writeHooksConfig(testDir);
const settingsPath = join(testDir, '.claude', 'settings.local.json'); const settingsPath = join(testDir, '.claude', 'settings.local.json');
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(parsed.hooks).toBeDefined(); expect(parsed.hooks).toBeDefined();
@@ -141,7 +141,7 @@ describe('writeHooksConfig', () => {
expect(parsed.hooks.Stop).toHaveLength(1); expect(parsed.hooks.Stop).toHaveLength(1);
}); });
it('should merge with existing settings.local.json', () => { it('should merge with existing settings.local.json', async () => {
const claudeDir = join(testDir, '.claude'); const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
writeFileSync( writeFileSync(
@@ -149,7 +149,7 @@ describe('writeHooksConfig', () => {
JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2), JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2),
); );
writeHooksConfig(testDir); await writeHooksConfig(testDir);
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
expect(parsed.existingKey).toBe('existingValue'); expect(parsed.existingKey).toBe('existingValue');
@@ -157,7 +157,7 @@ describe('writeHooksConfig', () => {
expect(parsed.hooks).toBeDefined(); expect(parsed.hooks).toBeDefined();
}); });
it('should overwrite existing hooks key', () => { it('should overwrite existing hooks key', async () => {
const claudeDir = join(testDir, '.claude'); const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
writeFileSync( writeFileSync(
@@ -165,26 +165,26 @@ describe('writeHooksConfig', () => {
JSON.stringify({ hooks: { oldHook: [] } }, null, 2), JSON.stringify({ hooks: { oldHook: [] } }, null, 2),
); );
writeHooksConfig(testDir); await writeHooksConfig(testDir);
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
expect(parsed.hooks.oldHook).toBeUndefined(); expect(parsed.hooks.oldHook).toBeUndefined();
expect(parsed.hooks.Notification).toBeDefined(); expect(parsed.hooks.Notification).toBeDefined();
}); });
it('should handle malformed existing settings.local.json', () => { it('should handle malformed existing settings.local.json', async () => {
const claudeDir = join(testDir, '.claude'); const claudeDir = join(testDir, '.claude');
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, 'settings.local.json'), 'not valid json{{{'); writeFileSync(join(claudeDir, 'settings.local.json'), 'not valid json{{{');
writeHooksConfig(testDir); await writeHooksConfig(testDir);
const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8'));
expect(parsed.hooks).toBeDefined(); expect(parsed.hooks).toBeDefined();
}); });
it('should end file with newline', () => { it('should end file with newline', async () => {
writeHooksConfig(testDir); await writeHooksConfig(testDir);
const content = readFileSync(join(testDir, '.claude', 'settings.local.json'), 'utf-8'); const content = readFileSync(join(testDir, '.claude', 'settings.local.json'), 'utf-8');
expect(content.endsWith('\n')).toBe(true); expect(content.endsWith('\n')).toBe(true);
}); });