Skip to content

JSONL Output Modes for FORGE Studio Integration

Date: 2026-03-16 Status: Approved Version bump: 0.9.0 → 0.10.0

Problem

FORGE Studio needs to consume JSONL output from reconstruction executables for real-time monitoring, but the current CLI only offers two modes:

  • Default: JSONL on stderr + auto-spawn forgeview (TUI)
  • --no-tui: Disable JSONL entirely, use classic spdlog+indicators

There is no way to get JSONL output without spawning forgeview, which is what FORGE Studio (and potentially other consumers like Yarra) needs.

Approach

Separate the two concerns — JSONL output and forgeview spawning — into independent CLI flags. This is a breaking change to --no-tui semantics, acceptable because the software has not been distributed yet.

CLI Flags

New flag definitions

Flag Effect
(none) JSONL on stderr, auto-spawn forgeview
--no-tui JSONL on stderr, no forgeview spawning
--no-jsonl Classic spdlog+indicators, no JSONL, no forgeview
--jsonl-output <dest> Redirect JSONL to stderr (default), stdout, or a file path. Implies --no-tui.

Flag interactions

Flags JSONL forgeview Output dest
(none) yes auto-spawn stderr → pipe to forgeview
--no-tui yes no stderr
--no-jsonl no no classic stderr
--no-tui --no-jsonl no no classic stderr
--jsonl-output /tmp/out.jsonl yes no file
--jsonl-output stdout yes no stdout

Precedence

  • --no-jsonl takes precedence over everything (disables JSONL, implies no forgeview).
  • --jsonl-output <dest> implies --no-tui only when dest is not stderr (the default value). Since --jsonl-output has default_value("stderr"), every invocation technically has it set; only explicit non-default values trigger the --no-tui implication.
  • --jsonl-output is ignored when --no-jsonl is present (logged as a warning).

Components

1. FORGE_LOG_INIT changes

File: forge/Core/ForgeLog.hpp

Updated signature:

inline void FORGE_LOG_INIT(
    const std::string& level_str = "info",
    const std::string& log_path = "forge.log",
    bool no_jsonl = false,
    const std::string& jsonl_output = "stderr")

The old bool no_tui parameter is replaced by bool no_jsonl. The no_tui (forgeview spawn) decision is handled separately via FORGE_TUI_START.

Sink selection:

if no_jsonl:
    stderr_color_sink (classic spdlog)
else if jsonl_output == "stdout":
    jsonl_stdout_sink (JSONL to stdout)
else if jsonl_output is a file path (not "stderr"):
    jsonl_file_sink (JSONL to file)
else:
    jsonl_stderr_sink (JSONL to stderr, default)

A file sink is always added alongside the JSONL/classic sink (writes to forge.log).

2. Global JSONL destination + parameterized sink

File: forge/Core/ForgeLog.hpp

Critical: JSONL is emitted from two places: the spdlog sink (log messages) and direct std::fwrite calls (progress, metrics, image preview, start, exit). Both must use the same configurable destination.

Add a global JSONL destination accessor:

inline FILE*& PG_JSONL_DEST(FILE* set_value = nullptr, bool do_set = false)
{
    static FILE* dest = stderr;
    if (do_set)
        dest = set_value;
    return dest;
}

FORGE_LOG_INIT sets this based on jsonl_output: - "stderr"PG_JSONL_DEST(stderr, true) - "stdout"PG_JSONL_DEST(stdout, true) - file path → PG_JSONL_DEST(fopen(path, "w"), true)

All seven direct JSONL emitter sites must change from std::fwrite(..., stderr) to std::fwrite(..., PG_JSONL_DEST()): - FORGE_TUI_START{"type":"start",...} - FORGE_TUI_EXIT{"type":"exit",...} - PGProgressManager::add{"type":"progress","action":"add",...} - PGProgressManager::tick{"type":"progress","action":"tick",...} - PGProgressManager::done{"type":"progress","action":"done",...} - PGProgressManager::metrics{"type":"metrics",...} - FORGE_IMAGE_PREVIEW{"type":"image_preview",...}

The spdlog sink is generalized to use PG_JSONL_DEST():

template <typename Mutex>
class jsonl_sink : public spdlog::sinks::base_sink<Mutex> {
protected:
    void sink_it_(const spdlog::details::log_msg& msg) override {
        // ... format as JSONL ...
        std::fwrite(buf, 1, len, PG_JSONL_DEST());
        std::fflush(PG_JSONL_DEST());
    }
};

File handle ownership: PG_JSONL_DEST() owns the FILE* for file output. FORGE_TUI_EXIT calls fclose(PG_JSONL_DEST()) only when the destination is not stderr or stdout. This happens before main() returns, before spdlog shutdown.

3. Forgeview spawn control

File: forge/Core/ForgeLog.hpp

Add a new global flag for spawn control, parallel to PG_TUI_MODE():

inline bool& PG_TUI_SPAWN_MODE(bool set_value = true, bool do_set = false)
{
    static bool mode = true; // default: spawn enabled
    if (do_set)
        mode = set_value;
    return mode;
}

FORGE_TUI_START checks both PG_TUI_MODE() and PG_TUI_SPAWN_MODE():

// Case 1: JSONL disabled (--no-jsonl) → classic start log, return
if (!PG_TUI_MODE()) {
    FORGE_INFO("Starting {} v{}", app_name, version);
    return;
}

// Case 2: JSONL enabled, spawn disabled (--no-tui) → emit JSONL start, return
if (!PG_TUI_SPAWN_MODE()) {
    emit_jsonl_start(app_name, version);  // writes to PG_JSONL_DEST()
    return;
}

// Case 3: JSONL enabled, spawn enabled (default) → try to spawn forgeview
bool spawned = tui.spawn(forgeview_path);
if (spawned) {
    emit_jsonl_start(app_name, version);
} else {
    // forgeview not found or stdout not a tty → fall back to classic
    FORGE_LOG_REINIT_CLASSIC();
    FORGE_INFO("Starting {} v{}", app_name, version);
}

FORGE_TUI_EXIT changes:

// Case 1: JSONL disabled → classic log
if (!PG_TUI_MODE()) {
    FORGE_INFO("Reconstruction finished (exit code {})", code);
    return;
}

// Case 2: JSONL enabled, forgeview active → emit exit, wait for user
if (tui.active) {
    emit_jsonl_exit(code);  // writes to PG_JSONL_DEST()
    tui.wait();
    return;
}

// Case 3: JSONL enabled, no forgeview (--no-tui mode) → emit exit only
emit_jsonl_exit(code);  // writes to PG_JSONL_DEST()

// Close file handle if JSONL went to a file (not stderr/stdout)
if (PG_JSONL_DEST() != stderr && PG_JSONL_DEST() != stdout) {
    fclose(PG_JSONL_DEST());
}

4. Executable changes

Files: apps/forgeSense.cpp, apps/forgePcSense.cpp, apps/forgePcSenseTimeSeg.cpp, apps/lowRank/oscillateRecon.cpp

Each executable's desc.add_options() changes:

Remove:

("no-tui", po::bool_switch()->default_value(false),
 "Disable JSONL/TUI output, use classic spdlog+indicators")

Add:

("no-tui", po::bool_switch()->default_value(false),
 "Disable forgeview spawning (JSONL output continues on stderr)")
("no-jsonl", po::bool_switch()->default_value(false),
 "Disable JSONL output, use classic spdlog+indicators")
("jsonl-output", po::value<std::string>()->default_value("stderr"),
 "JSONL output destination: stderr (default), stdout, or file path")

The FORGE_LOG_INIT call changes from:

FORGE_LOG_INIT(vm["log-level"].as<std::string>(), "forge.log", vm["no-tui"].as<bool>());
to:
bool no_jsonl = vm["no-jsonl"].as<bool>();
std::string jsonl_output = vm["jsonl-output"].as<std::string>();
bool no_tui = vm["no-tui"].as<bool>();
bool warn_jsonl_ignored = false;

// --jsonl-output implies --no-tui (only for non-default values)
if (jsonl_output != "stderr") {
    no_tui = true;
}
// --no-jsonl implies --no-tui
if (no_jsonl) {
    no_tui = true;
    if (jsonl_output != "stderr") {
        warn_jsonl_ignored = true;  // defer warning until after logger init
    }
}

// Initialize logger FIRST, then emit deferred warnings
FORGE_LOG_INIT(vm["log-level"].as<std::string>(), "forge.log", no_jsonl, jsonl_output);
PG_TUI_SPAWN_MODE(!no_tui, true);

if (warn_jsonl_ignored) {
    FORGE_WARN("--jsonl-output ignored because --no-jsonl is set");
}

5. Version bump

File: CMakeLists.txt line 9

Change:

project("forge" VERSION 0.9.0 LANGUAGES CXX)
to:
project("forge" VERSION 0.10.0 LANGUAGES CXX)

Testing

CLI flag tests (extend forge/Tests/CLITests.cpp)

  1. --no-tui keeps JSONL: Run with --no-tui, capture stderr, verify it contains JSONL lines (lines starting with {).
  2. --no-jsonl disables JSONL: Run with --no-jsonl, capture stderr, verify no JSONL lines.
  3. --jsonl-output <file>: Run with --jsonl-output /tmp/test_forge.jsonl, verify the file contains valid JSONL.
  4. --jsonl-output stdout: Run and capture stdout, verify JSONL appears there.
  5. Flag precedence: --no-jsonl --jsonl-output /tmp/x produces no JSONL output (no file created or empty).

Unit test for parameterized JSONL sink

Write JSONL to a temp file via the sink, verify valid JSON lines with correct type fields.

Backward compatibility

Verify default behavior (no flags) still emits JSONL on stderr and auto-spawns forgeview when available.

Documentation Updates

  • README.md: Update TUI monitoring section. Add FORGE Studio integration example (forgeSense [args] --no-tui). Document --no-tui, --no-jsonl, --jsonl-output flags.
  • CHANGELOG.md: Add entries under [v0.10.0] for the new flags and the --no-tui semantic change.
  • CLAUDE.md: Update the TUI Monitoring section to reflect new flag semantics.

Files Modified

File Change
CMakeLists.txt Version bump 0.9.0 → 0.10.0
forge/Core/ForgeLog.hpp Parameterized JSONL sink, FORGE_LOG_INIT new params, PG_TUI_SPAWN_MODE, updated FORGE_TUI_START
apps/forgeSense.cpp New CLI flags, updated init call
apps/forgePcSense.cpp Same
apps/forgePcSenseTimeSeg.cpp Same
apps/lowRank/oscillateRecon.cpp Same
forge/Tests/CLITests.cpp New flag interaction tests
README.md Document new flags, FORGE Studio usage
CHANGELOG.md v0.10.0 entries
CLAUDE.md Update TUI monitoring section

Files reviewed, unchanged

File Reason
forge/Solvers/solve_pwls_pcg.hpp References PG_TUI_MODE() for image preview gating; semantics unchanged (still true when JSONL active)

Existing test compatibility

Existing CLI tests in CLITests.cpp use --no-tui to suppress forgeview spawning. Under the new semantics, --no-tui keeps JSONL active, so stderr output captured by tests will contain JSONL lines. Tests that only check exit codes are unaffected. Tests that check stderr content may need updating to use --no-jsonl if they expect human-readable output.