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-jsonltakes precedence over everything (disables JSONL, implies no forgeview).--jsonl-output <dest>implies--no-tuionly when dest is notstderr(the default value). Since--jsonl-outputhasdefault_value("stderr"), every invocation technically has it set; only explicit non-default values trigger the--no-tuiimplication.--jsonl-outputis ignored when--no-jsonlis 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>());
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)
project("forge" VERSION 0.10.0 LANGUAGES CXX)
Testing¶
CLI flag tests (extend forge/Tests/CLITests.cpp)¶
--no-tuikeeps JSONL: Run with--no-tui, capture stderr, verify it contains JSONL lines (lines starting with{).--no-jsonldisables JSONL: Run with--no-jsonl, capture stderr, verify no JSONL lines.--jsonl-output <file>: Run with--jsonl-output /tmp/test_forge.jsonl, verify the file contains valid JSONL.--jsonl-output stdout: Run and capture stdout, verify JSONL appears there.- Flag precedence:
--no-jsonl --jsonl-output /tmp/xproduces 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-outputflags. - CHANGELOG.md: Add entries under
[v0.10.0]for the new flags and the--no-tuisemantic 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.