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();
+ });
+});