diff --git a/src/cli/main.ts b/src/cli/main.ts index 432739f..099e965 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -20,6 +20,7 @@ import { runSkillTasks, pluralize, writeJsonlReport, + getRunLogPath, type SkillTaskOptions, } from './output/index.js'; import { @@ -167,6 +168,11 @@ async function runSkills( reporter.success(`Wrote JSONL output to ${options.output}`); } + // Always write automatic run log for debugging + const runLogPath = getRunLogPath(cwd); + writeJsonlReport(runLogPath, reports, totalDuration); + reporter.debug(`Run log: ${runLogPath}`); + // Output results reporter.blank(); if (options.json) { @@ -466,6 +472,11 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise { @@ -174,3 +180,168 @@ describe('writeJsonlReport', () => { expect(summary.bySeverity.info).toBe(1); }); }); + +describe('getRunLogsDir', () => { + const originalEnv = process.env['WARDEN_STATE_DIR']; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env['WARDEN_STATE_DIR']; + } else { + process.env['WARDEN_STATE_DIR'] = originalEnv; + } + }); + + it('returns default path when WARDEN_STATE_DIR is not set', () => { + delete process.env['WARDEN_STATE_DIR']; + const result = getRunLogsDir(); + expect(result).toBe(join(homedir(), '.local', 'warden', 'runs')); + }); + + it('uses WARDEN_STATE_DIR when set', () => { + process.env['WARDEN_STATE_DIR'] = '/custom/state'; + const result = getRunLogsDir(); + expect(result).toBe('/custom/state/runs'); + }); +}); + +describe('generateRunLogFilename', () => { + it('generates filename with directory name and timestamp', () => { + const timestamp = new Date('2026-01-29T14:32:15.123Z'); + const result = generateRunLogFilename('/path/to/my-project', timestamp); + expect(result).toBe('my-project_2026-01-29T14-32-15.123Z.jsonl'); + }); + + it('replaces colons in timestamp with hyphens', () => { + const timestamp = new Date('2026-01-29T10:05:30.000Z'); + const result = generateRunLogFilename('/some/dir', timestamp); + expect(result).toMatch(/^\w+_2026-01-29T10-05-30\.000Z\.jsonl$/); + }); + + it('uses "unknown" for empty directory name', () => { + const timestamp = new Date('2026-01-29T12:00:00.000Z'); + const result = generateRunLogFilename('/', timestamp); + expect(result).toBe('unknown_2026-01-29T12-00-00.000Z.jsonl'); + }); + + it('handles directory paths with trailing slash', () => { + const timestamp = new Date('2026-01-29T12:00:00.000Z'); + // basename handles trailing slashes, so /foo/bar/ becomes 'bar' + const result = generateRunLogFilename('/foo/bar', timestamp); + expect(result).toBe('bar_2026-01-29T12-00-00.000Z.jsonl'); + }); +}); + +describe('getRunLogPath', () => { + const originalEnv = process.env['WARDEN_STATE_DIR']; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env['WARDEN_STATE_DIR']; + } else { + process.env['WARDEN_STATE_DIR'] = originalEnv; + } + }); + + it('returns full path combining logs dir and filename', () => { + delete process.env['WARDEN_STATE_DIR']; + const timestamp = new Date('2026-01-29T14:32:15.123Z'); + const result = getRunLogPath('/path/to/warden', timestamp); + expect(result).toBe( + join(homedir(), '.local', 'warden', 'runs', 'warden_2026-01-29T14-32-15.123Z.jsonl') + ); + }); + + it('respects WARDEN_STATE_DIR', () => { + process.env['WARDEN_STATE_DIR'] = '/custom/dir'; + const timestamp = new Date('2026-01-29T14:32:15.123Z'); + const result = getRunLogPath('/my/project', timestamp); + expect(result).toBe('/custom/dir/runs/project_2026-01-29T14-32-15.123Z.jsonl'); + }); +}); + +describe('automatic run logging integration', () => { + let testStateDir: string; + const originalEnv = process.env['WARDEN_STATE_DIR']; + + beforeEach(() => { + testStateDir = join(tmpdir(), `warden-state-${Date.now()}`); + process.env['WARDEN_STATE_DIR'] = testStateDir; + }); + + afterEach(() => { + if (existsSync(testStateDir)) { + rmSync(testStateDir, { recursive: true }); + } + if (originalEnv === undefined) { + delete process.env['WARDEN_STATE_DIR']; + } else { + process.env['WARDEN_STATE_DIR'] = originalEnv; + } + }); + + it('writes run log to auto-generated path', () => { + const reports: SkillReport[] = [ + { + skill: 'test-skill', + summary: 'Test complete', + findings: [ + { id: 'test-1', severity: 'low', title: 'Test', description: 'Test finding' }, + ], + durationMs: 100, + }, + ]; + + const timestamp = new Date('2026-01-29T14:32:15.123Z'); + const runLogPath = getRunLogPath('/path/to/my-project', timestamp); + + writeJsonlReport(runLogPath, reports, 500); + + // Verify file was created at expected location + expect(existsSync(runLogPath)).toBe(true); + expect(runLogPath).toBe(join(testStateDir, 'runs', 'my-project_2026-01-29T14-32-15.123Z.jsonl')); + + // Verify content + const content = readFileSync(runLogPath, 'utf-8'); + const lines = content.trim().split('\n'); + expect(lines.length).toBe(2); // 1 report + 1 summary + + const record = JSON.parse(lines[0]!) as JsonlRecord; + expect(record.skill).toBe('test-skill'); + expect(record.findings.length).toBe(1); + }); + + it('creates nested runs directory automatically', () => { + const runLogPath = getRunLogPath('/some/project', new Date()); + + // Directory shouldn't exist yet + expect(existsSync(join(testStateDir, 'runs'))).toBe(false); + + writeJsonlReport(runLogPath, [], 100); + + // Now it should exist with the file + expect(existsSync(runLogPath)).toBe(true); + }); + + it('handles multiple runs with unique timestamps', () => { + const timestamp1 = new Date('2026-01-29T14:00:00.000Z'); + const timestamp2 = new Date('2026-01-29T14:01:00.000Z'); + + const path1 = getRunLogPath('/project', timestamp1); + const path2 = getRunLogPath('/project', timestamp2); + + expect(path1).not.toBe(path2); + + writeJsonlReport(path1, [], 100); + writeJsonlReport(path2, [], 200); + + expect(existsSync(path1)).toBe(true); + expect(existsSync(path2)).toBe(true); + + // Verify they have different durations + const content1 = JSON.parse(readFileSync(path1, 'utf-8').trim()); + const content2 = JSON.parse(readFileSync(path2, 'utf-8').trim()); + expect(content1.run.durationMs).toBe(100); + expect(content2.run.durationMs).toBe(200); + }); +}); diff --git a/src/cli/output/jsonl.ts b/src/cli/output/jsonl.ts index 12a018b..082a302 100644 --- a/src/cli/output/jsonl.ts +++ b/src/cli/output/jsonl.ts @@ -1,8 +1,39 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; import type { SkillReport, UsageStats } from '../../types/index.js'; import { countBySeverity } from './formatters.js'; +/** + * Get the default run logs directory. + * Uses WARDEN_STATE_DIR env var if set, otherwise ~/.local/warden/runs + */ +export function getRunLogsDir(): string { + const stateDir = process.env['WARDEN_STATE_DIR']; + if (stateDir) { + return join(stateDir, 'runs'); + } + return join(homedir(), '.local', 'warden', 'runs'); +} + +/** + * Generate a run log filename from directory name and timestamp. + * Format: {dirname}_{timestamp}.jsonl + * Timestamp has colons replaced with hyphens for filesystem compatibility. + */ +export function generateRunLogFilename(cwd: string, timestamp: Date = new Date()): string { + const dirName = basename(cwd) || 'unknown'; + const ts = timestamp.toISOString().replace(/:/g, '-'); + return `${dirName}_${ts}.jsonl`; +} + +/** + * Get the full path for an automatic run log. + */ +export function getRunLogPath(cwd: string, timestamp: Date = new Date()): string { + return join(getRunLogsDir(), generateRunLogFilename(cwd, timestamp)); +} + /** * Metadata for a JSONL run record. */