diff --git a/src/commands/dream.ts b/src/commands/dream.ts index ab63457a6..b18679a4a 100644 --- a/src/commands/dream.ts +++ b/src/commands/dream.ts @@ -30,7 +30,15 @@ import { type CyclePhase, type CycleReport, } from '../core/cycle.ts'; -import { existsSync } from 'fs'; +import { + existsSync, + mkdirSync, + renameSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { join } from 'path'; interface DreamArgs { json: boolean; @@ -53,6 +61,14 @@ interface DreamArgs { * Never auto-applied for --input (codex finding #3). */ bypassDreamGuard: boolean; + /** + * Persist the CycleReport JSON to a per-cycle file plus a `latest.json` + * symlink. Replaces the common cron pattern where downstream wrappers + * (e.g. fork-side launchd glue) capture stdout, archive it, and atomically + * swap a `latest` pointer. Pairs with `--json`; no-op without it. + * The directory is auto-created. + */ + archiveDir: string | null; } const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -99,6 +115,9 @@ function parseArgs(args: string[]): DreamArgs { process.exit(2); } + const archiveDirIdx = args.indexOf('--archive-dir'); + const archiveDir = archiveDirIdx !== -1 ? args[archiveDirIdx + 1] ?? null : null; + // --input + --date / --from / --to is incoherent: --input is a single // file, the date filters scan a directory. if (inputFile && (date || from || to)) { @@ -121,9 +140,52 @@ function parseArgs(args: string[]): DreamArgs { from, to, bypassDreamGuard: args.includes('--unsafe-bypass-dream-guard'), + archiveDir, }; } +/** + * File-system-safe timestamp: `2026-04-23T19-42-07Z`. Strips colons and + * milliseconds so the result is sortable, portable, and acceptable on + * filesystems that reject `:` (e.g. SMB/Windows-shared NAS). + */ +function isoStamp(d = new Date()): string { + return d.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z'); +} + +/** + * Write the CycleReport JSON to `/.json` and atomically swap the + * `/latest.json` symlink to point at it. The dir is created if missing. + * + * The atomic swap pattern (`symlink → rename onto target`) guarantees that + * readers never observe a dangling symlink — `rename` on the same fs is + * atomic. Best-effort: on platforms or filesystems where `symlink` isn't + * supported (rare on macOS/Linux), the per-cycle file still lands; the + * `latest.json` swap simply no-ops with a stderr warning. + * + * Returns the absolute path of the per-cycle file. + */ +function archiveReport(dir: string, json: string, stamp: string = isoStamp()): string { + mkdirSync(dir, { recursive: true }); + + const target = join(dir, `${stamp}.json`); + writeFileSync(target, json); + + const latest = join(dir, 'latest.json'); + const tmp = join(dir, '.latest.json.tmp'); + try { + if (existsSync(tmp)) unlinkSync(tmp); + symlinkSync(`${stamp}.json`, tmp); + renameSync(tmp, latest); + } catch (err) { + console.error( + `[dream] archive: per-cycle file written but latest.json symlink swap failed: ${(err as Error).message}`, + ); + } + + return target; +} + /** * Resolve the brain directory without the `findRepoRoot` footgun. * @@ -192,6 +254,12 @@ Options: guard is firing. Loud stderr warning + cost reminder fires every run. + --archive-dir + Persist the CycleReport JSON to /.json and + atomically swap a /latest.json symlink. Pairs with + --json; no-op without it. Replaces ad-hoc downstream + stdout-capture wrappers. Directory is auto-created. + --help, -h Show this help Examples: @@ -288,9 +356,24 @@ export async function runDream(engine: BrainEngine | null, args: string[]): Prom }); if (opts.json) { - console.log(JSON.stringify(report, null, 2)); + const json = JSON.stringify(report, null, 2); + console.log(json); + if (opts.archiveDir) { + try { + const path = archiveReport(opts.archiveDir, json); + console.error(`[dream] archived report → ${path}`); + } catch (err) { + console.error(`[dream] archive write failed: ${(err as Error).message}`); + process.exit(2); + } + } } else { printHuman(report); + if (opts.archiveDir) { + console.error( + '[dream] --archive-dir requires --json; report not archived (printed human format only)', + ); + } } // Exit non-zero when the cycle failed overall (helps cron spot real problems). diff --git a/test/dream-cli-flags.test.ts b/test/dream-cli-flags.test.ts index 5d8b82714..4f5f29ff8 100644 --- a/test/dream-cli-flags.test.ts +++ b/test/dream-cli-flags.test.ts @@ -58,4 +58,29 @@ describe('dream CLI flag wiring', () => { expect(dreamSrc).toContain('skips the Sonnet'); expect(dreamSrc.toLowerCase()).toContain('zero llm calls'); }); + + test('declares --archive-dir flag with archiveDir field', () => { + expect(dreamSrc).toContain("'--archive-dir'"); + expect(dreamSrc).toContain('archiveDir'); + }); + + test('archiveReport helper writes per-cycle file + atomic latest.json symlink', () => { + // Source-text introspection — the runtime path is exercised end-to-end + // in test/dream.test.ts. + expect(dreamSrc).toMatch(/function\s+archiveReport/); + expect(dreamSrc).toContain('latest.json'); + expect(dreamSrc).toContain('renameSync'); + expect(dreamSrc).toContain('symlinkSync'); + }); + + test('--archive-dir is no-op without --json (help text says so)', () => { + expect(dreamSrc).toContain('Pairs with'); + expect(dreamSrc).toMatch(/requires --json/); + }); + + test('isoStamp returns colon-free, sortable, fs-safe timestamp', () => { + // Filename-safety is a contract — must not contain ':' or '.' + expect(dreamSrc).toMatch(/function\s+isoStamp/); + expect(dreamSrc).toContain("replace(/[:.]/g, '-')"); + }); }); diff --git a/test/dream.test.ts b/test/dream.test.ts index a773e51a8..9b01ba861 100644 --- a/test/dream.test.ts +++ b/test/dream.test.ts @@ -17,7 +17,7 @@ */ import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; -import { mkdtempSync, rmSync } from 'fs'; +import { existsSync, lstatSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { execSync } from 'child_process'; @@ -241,3 +241,97 @@ describe('runDream — exit-code semantics', () => { spy.mockRestore(); }); }); + +// ─── --archive-dir behavior ──────────────────────────────────────── + +describe('runDream — --archive-dir persists CycleReport + latest.json symlink', () => { + let repo: string; + let engine: InstanceType; + let archiveDir: string; + + beforeEach(async () => { + repo = makeGitRepo(); + engine = await makePGLite(); + archiveDir = mkdtempSync(join(tmpdir(), 'gbrain-dream-archive-')); + }, 60_000); + + afterEach(async () => { + if (engine) await engine.disconnect(); + rmSync(repo, { recursive: true, force: true }); + rmSync(archiveDir, { recursive: true, force: true }); + }, 60_000); + + test('writes per-cycle JSON file + latest.json symlink pointing at it', async () => { + await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', archiveDir]); + + const entries = readdirSync(archiveDir); + // exactly one .json + one latest.json symlink + const cycleFiles = entries.filter((f) => f.endsWith('.json') && f !== 'latest.json'); + expect(cycleFiles.length).toBe(1); + expect(entries).toContain('latest.json'); + + // latest.json is a symlink (lstat) pointing at the cycle file + const latestPath = join(archiveDir, 'latest.json'); + expect(lstatSync(latestPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(latestPath)).toBe(cycleFiles[0]); + + // The cycle file itself contains a valid CycleReport JSON + const content = readFileSync(join(archiveDir, cycleFiles[0]), 'utf-8'); + const report = JSON.parse(content); + expect(report.status).toBeDefined(); + expect(report.phases).toBeInstanceOf(Array); + expect(report.brain_dir).toBe(repo); + }); + + test('cycle filenames are colon-free + sortable (fs-safe ISO timestamp)', async () => { + await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', archiveDir]); + + const cycleFiles = readdirSync(archiveDir).filter((f) => f.endsWith('.json') && f !== 'latest.json'); + expect(cycleFiles.length).toBe(1); + expect(cycleFiles[0]).not.toContain(':'); + expect(cycleFiles[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\.json$/); + }); + + test('second run atomically swaps latest.json to the new cycle file', async () => { + await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', archiveDir]); + const firstTarget = readlinkSync(join(archiveDir, 'latest.json')); + + // 1 s sleep so ISO timestamps differ (sec precision). + await new Promise((r) => setTimeout(r, 1100)); + + await runDream(engine, ['--dir', repo, '--phase', 'orphans', '--json', '--archive-dir', archiveDir]); + const secondTarget = readlinkSync(join(archiveDir, 'latest.json')); + + expect(secondTarget).not.toBe(firstTarget); + + // Both per-cycle files still exist (we never delete history). + const cycleFiles = readdirSync(archiveDir).filter((f) => f.endsWith('.json') && f !== 'latest.json'); + expect(cycleFiles.length).toBe(2); + expect(cycleFiles).toContain(firstTarget); + expect(cycleFiles).toContain(secondTarget); + }); + + test('--archive-dir auto-creates a missing destination directory', async () => { + const deepDir = join(archiveDir, 'does', 'not', 'exist', 'yet'); + expect(existsSync(deepDir)).toBe(false); + + await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json', '--archive-dir', deepDir]); + + expect(existsSync(deepDir)).toBe(true); + expect(existsSync(join(deepDir, 'latest.json'))).toBe(true); + }); + + test('--archive-dir without --json is a no-op + emits a stderr warning', async () => { + const errSpy = spyOn(console, 'error').mockImplementation(() => {}); + await runDream(engine, ['--dir', repo, '--phase', 'lint', '--archive-dir', archiveDir]); + + // No files were written (no --json, no archive) + expect(readdirSync(archiveDir).length).toBe(0); + // Warning was emitted + const warned = errSpy.mock.calls.some((c) => + String(c[0]).includes('--archive-dir requires --json'), + ); + expect(warned).toBe(true); + errSpy.mockRestore(); + }); +});