Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ pnpm-debug.log*
# Test coverage
coverage/

# Warden logs
.warden/logs/
# Warden artifacts (logs, sessions, etc.)
.warden/

# Temporary files
*.tmp
Expand Down
4 changes: 4 additions & 0 deletions src/action/triggers/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export async function executeTrigger(
batchDelayMs: config.defaults?.batchDelayMs,
pathToClaudeCodeExecutable: claudePath,
auxiliaryMaxRetries: config.defaults?.auxiliaryMaxRetries,
session: {
enabled: config.sessions?.enabled ?? true,
directory: config.sessions?.directory,
},
},
};

Expand Down
17 changes: 10 additions & 7 deletions src/cli/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,20 +144,23 @@ export async function runInit(options: CLIOptions, reporter: Reporter): Promise<
filesCreated++;
}

// Ensure .warden/logs/ is in .gitignore
// Ensure .warden/ is in .gitignore
const gitignorePath = join(repoRoot, '.gitignore');
if (existsSync(gitignorePath)) {
const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
const hasEntry = gitignoreContent.split('\n').some((line) => line.trim() === '.warden/logs/');
if (!hasEntry) {
const hasWardenEntry = gitignoreContent.split('\n').some((line) => {
const trimmed = line.trim();
return trimmed === '.warden/' || trimmed === '.warden';
});
if (!hasWardenEntry) {
const newline = gitignoreContent.endsWith('\n') ? '' : '\n';
writeFileSync(gitignorePath, gitignoreContent + newline + '.warden/logs/\n', 'utf-8');
reporter.created('.gitignore entry for .warden/logs/');
writeFileSync(gitignorePath, gitignoreContent + newline + '.warden/\n', 'utf-8');
reporter.created('.gitignore entry for .warden/');
filesCreated++;
}
} else {
writeFileSync(gitignorePath, '.warden/logs/\n', 'utf-8');
reporter.created('.gitignore with .warden/logs/');
writeFileSync(gitignorePath, '.warden/\n', 'utf-8');
reporter.created('.gitignore with .warden/');
filesCreated++;
}

Expand Down
42 changes: 21 additions & 21 deletions src/cli/log-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdirSync, rmSync, writeFileSync, utimesSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { findExpiredLogs, cleanupLogs } from './log-cleanup.js';
import { findExpiredArtifacts, cleanupArtifacts } from './log-cleanup.js';
import { Reporter } from './output/reporter.js';
import { detectOutputMode } from './output/tty.js';
import { Verbosity } from './output/verbosity.js';
Expand All @@ -20,7 +20,7 @@ function createLogFile(dir: string, name: string, daysOld: number): string {
return filePath;
}

describe('findExpiredLogs', () => {
describe('findExpiredArtifacts', () => {
let testDir: string;

beforeEach(() => {
Expand All @@ -35,21 +35,21 @@ describe('findExpiredLogs', () => {
});

it('returns empty array when directory does not exist', () => {
const result = findExpiredLogs('/nonexistent/path', 30);
const result = findExpiredArtifacts('/nonexistent/path', 30);
expect(result).toEqual([]);
});

it('returns empty array when no files are expired', () => {
createLogFile(testDir, 'recent.jsonl', 1);
const result = findExpiredLogs(testDir, 30);
const result = findExpiredArtifacts(testDir, 30);
expect(result).toEqual([]);
});

it('returns expired .jsonl files', () => {
createLogFile(testDir, 'old.jsonl', 45);
createLogFile(testDir, 'recent.jsonl', 1);

const result = findExpiredLogs(testDir, 30);
const result = findExpiredArtifacts(testDir, 30);
expect(result).toHaveLength(1);
expect(result[0]).toContain('old.jsonl');
});
Expand All @@ -60,21 +60,21 @@ describe('findExpiredLogs', () => {
const mtime = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000);
utimesSync(nonJsonl, mtime, mtime);

const result = findExpiredLogs(testDir, 30);
const result = findExpiredArtifacts(testDir, 30);
expect(result).toEqual([]);
});

it('respects custom retention days', () => {
createLogFile(testDir, 'a.jsonl', 10);
createLogFile(testDir, 'b.jsonl', 3);

expect(findExpiredLogs(testDir, 7)).toHaveLength(1);
expect(findExpiredLogs(testDir, 2)).toHaveLength(2);
expect(findExpiredLogs(testDir, 15)).toHaveLength(0);
expect(findExpiredArtifacts(testDir, 7)).toHaveLength(1);
expect(findExpiredArtifacts(testDir, 2)).toHaveLength(2);
expect(findExpiredArtifacts(testDir, 15)).toHaveLength(0);
});
});

describe('cleanupLogs', () => {
describe('cleanupArtifacts', () => {
let testDir: string;

beforeEach(() => {
Expand All @@ -91,8 +91,8 @@ describe('cleanupLogs', () => {
it('does nothing in "never" mode', async () => {
createLogFile(testDir, 'old.jsonl', 45);

const deleted = await cleanupLogs({
logsDir: testDir,
const deleted = await cleanupArtifacts({
dir: testDir,
retentionDays: 30,
mode: 'never',
isTTY: false,
Expand All @@ -107,8 +107,8 @@ describe('cleanupLogs', () => {
createLogFile(testDir, 'old.jsonl', 45);
createLogFile(testDir, 'recent.jsonl', 1);

const deleted = await cleanupLogs({
logsDir: testDir,
const deleted = await cleanupArtifacts({
dir: testDir,
retentionDays: 30,
mode: 'auto',
isTTY: false,
Expand All @@ -123,8 +123,8 @@ describe('cleanupLogs', () => {
it('does nothing in "ask" mode when not TTY', async () => {
createLogFile(testDir, 'old.jsonl', 45);

const deleted = await cleanupLogs({
logsDir: testDir,
const deleted = await cleanupArtifacts({
dir: testDir,
retentionDays: 30,
mode: 'ask',
isTTY: false,
Expand All @@ -138,8 +138,8 @@ describe('cleanupLogs', () => {
it('returns 0 when no expired files exist', async () => {
createLogFile(testDir, 'recent.jsonl', 1);

const deleted = await cleanupLogs({
logsDir: testDir,
const deleted = await cleanupArtifacts({
dir: testDir,
retentionDays: 30,
mode: 'auto',
isTTY: false,
Expand All @@ -149,9 +149,9 @@ describe('cleanupLogs', () => {
expect(deleted).toBe(0);
});

it('returns 0 when logsDir does not exist', async () => {
const deleted = await cleanupLogs({
logsDir: '/nonexistent/path',
it('returns 0 when dir does not exist', async () => {
const deleted = await cleanupArtifacts({
dir: '/nonexistent/path',
retentionDays: 30,
mode: 'auto',
isTTY: false,
Expand Down
23 changes: 12 additions & 11 deletions src/cli/log-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ import type { Reporter } from './output/reporter.js';
import { readSingleKey } from './input.js';

/**
* Find .jsonl log files in a directory that are older than retentionDays.
* Find .jsonl files in a directory that are older than retentionDays.
*/
export function findExpiredLogs(logsDir: string, retentionDays: number): string[] {
export function findExpiredArtifacts(dir: string, retentionDays: number): string[] {
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;

let entries: string[];
try {
entries = readdirSync(logsDir);
entries = readdirSync(dir);
} catch {
return [];
}

const expired: string[] = [];
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const fullPath = join(logsDir, entry);
const fullPath = join(dir, entry);
try {
const stat = statSync(fullPath);
if (stat.mtimeMs < cutoff) {
Expand All @@ -35,28 +35,29 @@ export function findExpiredLogs(logsDir: string, retentionDays: number): string[
}

/**
* Clean up expired log files based on the configured mode.
* Clean up expired .jsonl artifact files based on the configured mode.
* Works for both log and session directories.
* Returns the number of files deleted.
*/
export async function cleanupLogs(opts: {
logsDir: string;
export async function cleanupArtifacts(opts: {
dir: string;
retentionDays: number;
mode: LogCleanupMode;
isTTY: boolean;
reporter: Reporter;
}): Promise<number> {
const { logsDir, retentionDays, mode, isTTY, reporter } = opts;
const { dir, retentionDays, mode, isTTY, reporter } = opts;

if (mode === 'never') return 0;

const expired = findExpiredLogs(logsDir, retentionDays);
const expired = findExpiredArtifacts(dir, retentionDays);
if (expired.length === 0) return 0;

if (mode === 'ask') {
if (!isTTY || !process.stdin.isTTY) return 0;

process.stderr.write(
`Found ${expired.length} log ${expired.length === 1 ? 'file' : 'files'} older than ${retentionDays} days. Remove? [y/N] `
`Found ${expired.length} expired ${expired.length === 1 ? 'file' : 'files'} older than ${retentionDays} days. Remove? [y/N] `
);
const key = await readSingleKey();
process.stderr.write(key + '\n');
Expand All @@ -75,7 +76,7 @@ export async function cleanupLogs(opts: {
}

if (deleted > 0) {
reporter.debug(`Cleaned up ${deleted} expired log ${deleted === 1 ? 'file' : 'files'}`);
reporter.debug(`Cleaned up ${deleted} expired ${deleted === 1 ? 'file' : 'files'}`);
}

return deleted;
Expand Down
41 changes: 29 additions & 12 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
generateRunId,
type SkillTaskOptions,
} from './output/index.js';
import { cleanupLogs } from './log-cleanup.js';
import { cleanupArtifacts } from './log-cleanup.js';
import { resolveSessionsDir } from '../sdk/session.js';
import {
collectFixableFindings,
applyAllFixes,
Expand Down Expand Up @@ -192,9 +193,10 @@ async function outputResultsAndHandleFixes(

// Always write repo-local JSONL log (non-fatal — don't lose analysis output)
const logPath = getRepoLogPath(repoPath, runId, timestamp);
let logWritten = false;
try {
writeJsonlContent(logPath, jsonlContent);
reporter.debug(`Run log: ${logPath}`);
logWritten = true;
} catch (err) {
reporter.warning(`Failed to write run log: ${err instanceof Error ? err.message : String(err)}`);
}
Expand Down Expand Up @@ -243,6 +245,11 @@ async function outputResultsAndHandleFixes(
reporter.blank();
reporter.renderSummary(filteredReports, totalDuration, { traceId });

// Show log file path after summary (only if write succeeded)
if (!options.json && logWritten) {
reporter.dim(`Log: ${logPath}`);
}

// Handle fixes: --fix (automatic) always runs, interactive step-through in TTY mode
if (fixableFindings.length > 0) {
if (options.fix) {
Expand Down Expand Up @@ -363,6 +370,7 @@ async function runSkills(
batchDelayMs: config?.defaults?.batchDelayMs,
maxContextFiles: config?.defaults?.chunking?.maxContextFiles,
auxiliaryMaxRetries: config?.defaults?.auxiliaryMaxRetries,
session: config?.sessions ?? { enabled: true },
};
const tasks: SkillTaskOptions[] = skillsToRun.map(({ skill, remote, filters }) => ({
name: skill,
Expand Down Expand Up @@ -644,6 +652,7 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise<n
maxTurns: trigger.maxTurns,
maxContextFiles: config.defaults?.chunking?.maxContextFiles,
auxiliaryMaxRetries: config.defaults?.auxiliaryMaxRetries,
session: config.sessions ?? { enabled: true },
},
}));

Expand Down Expand Up @@ -819,20 +828,28 @@ export async function main(): Promise<void> {
},
);

// Run log cleanup after all output is complete (covers all exit paths)
// Run log and session cleanup after all output is complete (covers all exit paths)
try {
let logsRoot: string;
let cleanupRoot: string;
try {
logsRoot = getRepoRoot(cwd);
cleanupRoot = getRepoRoot(cwd);
} catch {
logsRoot = cwd;
cleanupRoot = cwd;
}
const cfgPath = resolve(logsRoot, 'warden.toml');
const logsConfig = existsSync(cfgPath) ? loadWardenConfig(dirname(cfgPath)).logs : undefined;
await cleanupLogs({
logsDir: join(logsRoot, '.warden', 'logs'),
retentionDays: logsConfig?.retentionDays ?? 30,
mode: logsConfig?.cleanup ?? 'ask',
const cfgPath = resolve(cleanupRoot, 'warden.toml');
const cfg = existsSync(cfgPath) ? loadWardenConfig(dirname(cfgPath)) : undefined;
await cleanupArtifacts({
dir: join(cleanupRoot, '.warden', 'logs'),
retentionDays: cfg?.logs?.retentionDays ?? 30,
mode: cfg?.logs?.cleanup ?? 'ask',
isTTY: reporter.mode.isTTY,
reporter,
});
// Session cleanup mirrors log cleanup
await cleanupArtifacts({
dir: resolveSessionsDir(cleanupRoot, cfg?.sessions?.directory),
retentionDays: cfg?.sessions?.retentionDays ?? 7,
mode: cfg?.sessions?.cleanup ?? 'auto',
isTTY: reporter.mode.isTTY,
reporter,
});
Expand Down
6 changes: 3 additions & 3 deletions src/cli/output/jsonl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,13 +428,13 @@ describe('getRepoLogPath', () => {
it('returns path under .warden/logs/', () => {
const timestamp = new Date('2026-02-18T14:32:15.123Z');
const result = getRepoLogPath('/path/to/repo', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', timestamp);
expect(result).toBe('/path/to/repo/.warden/logs/2026-02-18T14-32-15.123Z-a1b2c3d4.jsonl');
expect(result).toBe('/path/to/repo/.warden/logs/a1b2c3d4-2026-02-18T14-32-15-123Z.jsonl');
});

it('replaces colons in timestamp with hyphens', () => {
it('replaces colons and dots in timestamp with hyphens', () => {
const timestamp = new Date('2026-02-18T10:05:30.000Z');
const result = getRepoLogPath('/repo', 'abcdef12-3456-7890-abcd-ef1234567890', timestamp);
expect(result).toMatch(/2026-02-18T10-05-30\.000Z-abcdef12\.jsonl$/);
expect(result).toMatch(/abcdef12-2026-02-18T10-05-30-000Z\.jsonl$/);
});

it('uses different runId for different paths', () => {
Expand Down
6 changes: 3 additions & 3 deletions src/cli/output/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ export function shortRunId(runId: string): string {

/**
* Get the repo-local log file path.
* Returns: {repoRoot}/.warden/logs/{ISO-datetime}-{runId8}.jsonl
* Returns: {repoRoot}/.warden/logs/{runId8}-{ISO-datetime}.jsonl
*/
export function getRepoLogPath(repoRoot: string, runId: string, timestamp: Date = new Date()): string {
const ts = timestamp.toISOString().replace(/:/g, '-');
return join(repoRoot, '.warden', 'logs', `${ts}-${shortRunId(runId)}.jsonl`);
const ts = timestamp.toISOString().replace(/[:.]/g, '-');
return join(repoRoot, '.warden', 'logs', `${shortRunId(runId)}-${ts}.jsonl`);
}

/**
Expand Down
15 changes: 15 additions & 0 deletions src/cli/output/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,21 @@ export class Reporter {
// No tips in CI mode
}

/**
* Log dim/subtle text (visible at normal verbosity, hidden in quiet mode).
*/
dim(message: string): void {
if (this.verbosity === Verbosity.Quiet) {
return;
}

if (this.mode.isTTY) {
this.log(chalk.dim(message));
} else {
this.logPlain(message);
}
}

/**
* Log plain text (no prefix).
*/
Expand Down
Loading