Skip to content

Commit 2e4b75e

Browse files
NagyViktNagyVikt
andauthored
perf(output): default to terse output when stdout is non-TTY (#585)
* perf(cli): lazy-load subcommand modules to cut startup latency Top-level requires for subcommand modules (hooks, sandbox, toolchain, finish, doctor, submodule, agents/*, report/session-severity, budget, ci-init, cockpit, pr-review) are now wrapped in a memoizing Proxy that defers the underlying `require()` until the first property access. Every `gx <verb>` invocation previously paid the cost of loading the full subcommand graph (3.9k-line dispatcher pulls in ~15 module trees). With the Proxy, only the verb's own module(s) are walked — `gx --help` and `gx help` now skip doctor, cockpit, finish, budget, ci-init, submodule, pr-review, and the agents/* family entirely. Measured on this worktree (15 samples each): command before after delta ------------------------------------- gx --help ~90ms ~50ms -40ms (-44%) gx help ~100ms ~40ms -60ms (-60%) gx doctor* ~70ms ~50ms -20ms (-29%) (*doctor early-aborts on a non-existent --target) Anything used by `--help` rendering or the no-arg cockpit/status default path stays eager (context, output, scaffold, git, core/runtime, args, dispatch). Only true subcommand modules are deferred. Property-only access through the Proxy is a safe drop-in: there is no spread / destructure / Object.keys against these module objects in main.js. The single destructured import (`finishAgentSession`) is replaced with a thin forwarding arrow that defers the load to call time. Verified: - `node --test test/*.test.js` — net new failures: 0 vs the clean worktree baseline (27 pre-existing failures from parallel in-flight work on src/context.js, src/doctor, src/git). - `cli main no longer keeps local copies of extracted shared helpers or dead cleanup code` (a structure-asserting test) still passes; the lazy wrapper preserves the literal `require('../<module>')` tokens the test scans for. - `node bin/multiagent-safety.js --help` exits 0. - `node bin/multiagent-safety.js status` runs to completion on this repo. * perf(output): default to terse output when stdout is non-TTY --------- Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent f5a9dc1 commit 2e4b75e

4 files changed

Lines changed: 152 additions & 33 deletions

File tree

src/cli/main.js

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
11
#!/usr/bin/env node
22

3-
const hooksModule = require('../hooks');
4-
const sandboxModule = require('../sandbox');
5-
const toolchainModule = require('../toolchain');
6-
const finishCommands = require('../finish');
7-
const doctorModule = require('../doctor');
8-
const submoduleModule = require('../submodule');
9-
const agentInspect = require('../agents/inspect');
10-
const agentStatus = require('../agents/status');
11-
const agentCleanupSessions = require('../agents/cleanup-sessions');
12-
const { finishAgentSession } = require('../agents/finish');
13-
const sessionSeverityReport = require('../report/session-severity');
14-
const budgetModule = require('../budget');
15-
const ciInitModule = require('../ci-init');
16-
const cockpitModule = require('../cockpit');
17-
const agentsStart = require('../agents/start');
18-
const prReviewModule = require('../pr-review');
3+
// Lazy-load heavy subcommand modules so `gx --help`, `gx help`, and dispatch
4+
// to unrelated verbs do not pay the cost of loading every subcommand graph.
5+
// Each handler only touches its own module(s); accesses are always property
6+
// reads (`x.foo(...)`), so a memoizing Proxy that defers the `require()`
7+
// is a safe drop-in.
8+
function lazyProxy(loader) {
9+
let cached = null;
10+
return new Proxy(Object.create(null), {
11+
get(_target, prop) {
12+
if (cached === null) {
13+
cached = loader();
14+
}
15+
return cached[prop];
16+
},
17+
has(_target, prop) {
18+
if (cached === null) {
19+
cached = loader();
20+
}
21+
return prop in cached;
22+
},
23+
});
24+
}
25+
26+
const hooksModule = lazyProxy(() => require('../hooks'));
27+
const sandboxModule = lazyProxy(() => require('../sandbox'));
28+
const toolchainModule = lazyProxy(() => require('../toolchain'));
29+
const finishCommands = lazyProxy(() => require('../finish'));
30+
const doctorModule = lazyProxy(() => require('../doctor'));
31+
const submoduleModule = lazyProxy(() => require('../submodule'));
32+
const agentInspect = lazyProxy(() => require('../agents/inspect'));
33+
const agentStatus = lazyProxy(() => require('../agents/status'));
34+
const agentCleanupSessions = lazyProxy(() => require('../agents/cleanup-sessions'));
35+
const agentsFinishModule = lazyProxy(() => require('../agents/finish'));
36+
const finishAgentSession = (...callArgs) => agentsFinishModule.finishAgentSession(...callArgs);
37+
const sessionSeverityReport = lazyProxy(() => require('../report/session-severity'));
38+
const budgetModule = lazyProxy(() => require('../budget'));
39+
const ciInitModule = lazyProxy(() => require('../ci-init'));
40+
const cockpitModule = lazyProxy(() => require('../cockpit'));
41+
const agentsStart = lazyProxy(() => require('../agents/start'));
42+
const prReviewModule = lazyProxy(() => require('../pr-review'));
1943
const {
2044
fs,
2145
path,

src/doctor/index.js

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const {
3131
cleanupProtectedBaseSandbox,
3232
} = require('../sandbox');
3333
const { ensureOmxScaffold, configureHooks } = require('../scaffold');
34-
const { detectRecoverableAutoFinishConflict, printAutoFinishSummary } = require('../output');
34+
const { detectRecoverableAutoFinishConflict, printAutoFinishSummary, isTerseMode } = require('../output');
3535
const { autoCommitWorktreeForFinish } = require('../finish');
3636

3737
/**
@@ -1152,6 +1152,7 @@ function emitDoctorSandboxJsonOutput(nestedResult, execution) {
11521152
}
11531153

11541154
function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, nestedResult, execution) {
1155+
const terse = isTerseMode();
11551156
console.log(
11561157
`[${TOOL_NAME}] doctor detected protected branch '${blocked.branch}'. ` +
11571158
`Running repairs in sandbox branch '${metadata.branch || 'agent/<auto>'}'.`,
@@ -1164,6 +1165,10 @@ function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult,
11641165
return;
11651166
}
11661167

1168+
// Terse mode: drop "[OK] X skipped because of Y" / "already in sync"
1169+
// confirmations. Keep committed/failed/pending/merged states verbose so
1170+
// operators still see action-required hints, PR URLs, branch names, and
1171+
// file paths.
11671172
if (execution.autoCommit.status === 'committed') {
11681173
console.log(
11691174
`[${TOOL_NAME}] Auto-committed doctor repairs in sandbox branch '${metadata.branch}'.`,
@@ -1172,31 +1177,35 @@ function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult,
11721177
console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit failed; branch left for manual follow-up.`);
11731178
if (execution.autoCommit.stdout) process.stdout.write(execution.autoCommit.stdout);
11741179
if (execution.autoCommit.stderr) process.stderr.write(execution.autoCommit.stderr);
1175-
} else {
1180+
} else if (!terse) {
11761181
console.log(`[${TOOL_NAME}] Doctor sandbox auto-commit skipped: ${execution.autoCommit.note}.`);
11771182
}
11781183

11791184
if (execution.protectedBaseRepairSync.status === 'merged') {
11801185
console.log(`[${TOOL_NAME}] Fast-forwarded tracked doctor repairs into the protected branch workspace.`);
1181-
} else if (execution.protectedBaseRepairSync.status === 'unchanged') {
1182-
console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
11831186
} else if (execution.protectedBaseRepairSync.status === 'would-merge') {
11841187
console.log(`[${TOOL_NAME}] Dry run: would fast-forward tracked doctor repairs into the protected branch workspace.`);
11851188
} else if (execution.protectedBaseRepairSync.status === 'failed') {
11861189
console.log(`[${TOOL_NAME}] Protected branch tracked repair merge failed: ${execution.protectedBaseRepairSync.note}.`);
11871190
if (execution.protectedBaseRepairSync.stdout) process.stdout.write(execution.protectedBaseRepairSync.stdout);
11881191
if (execution.protectedBaseRepairSync.stderr) process.stderr.write(execution.protectedBaseRepairSync.stderr);
1189-
} else {
1190-
console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${execution.protectedBaseRepairSync.note}.`);
1192+
} else if (!terse) {
1193+
if (execution.protectedBaseRepairSync.status === 'unchanged') {
1194+
console.log(`[${TOOL_NAME}] Protected branch workspace already had the tracked doctor repairs.`);
1195+
} else {
1196+
console.log(`[${TOOL_NAME}] Protected branch tracked repair merge skipped: ${execution.protectedBaseRepairSync.note}.`);
1197+
}
11911198
}
11921199

11931200
if (execution.lockSync.status === 'synced') {
11941201
console.log(
11951202
`[${TOOL_NAME}] Synced repaired lock registry back to protected branch workspace (${LOCK_FILE_RELATIVE}).`,
11961203
);
11971204
} else if (execution.lockSync.status === 'unchanged') {
1205+
// Kept verbose in terse mode too: downstream consumers (and tests) rely
1206+
// on seeing the lock-registry sync stage reach a terminal state line.
11981207
console.log(`[${TOOL_NAME}] Lock registry already synced in protected branch workspace.`);
1199-
} else {
1208+
} else if (!terse) {
12001209
console.log(`[${TOOL_NAME}] Lock registry sync skipped: ${execution.lockSync.note}.`);
12011210
}
12021211

@@ -1217,7 +1226,7 @@ function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult,
12171226
console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`);
12181227
if (execution.finish.stdout) process.stdout.write(execution.finish.stdout);
12191228
if (execution.finish.stderr) process.stderr.write(execution.finish.stderr);
1220-
} else {
1229+
} else if (!terse) {
12211230
console.log(`[${TOOL_NAME}] Auto-finish skipped: ${execution.finish.note}.`);
12221231
}
12231232

@@ -1227,12 +1236,14 @@ function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult,
12271236
});
12281237
if (execution.omxScaffoldSync.status === 'synced') {
12291238
console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`);
1230-
} else if (execution.omxScaffoldSync.status === 'unchanged') {
1231-
console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
12321239
} else if (execution.omxScaffoldSync.status === 'would-sync') {
12331240
console.log(`[${TOOL_NAME}] Dry run: would sync .omx scaffold back to protected branch workspace.`);
1234-
} else {
1235-
console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${execution.omxScaffoldSync.note}.`);
1241+
} else if (!terse) {
1242+
if (execution.omxScaffoldSync.status === 'unchanged') {
1243+
console.log(`[${TOOL_NAME}] .omx scaffold already aligned in protected branch workspace.`);
1244+
} else {
1245+
console.log(`[${TOOL_NAME}] .omx scaffold sync skipped: ${execution.omxScaffoldSync.note}.`);
1246+
}
12361247
}
12371248
}
12381249

src/finish/index.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { TOOL_NAME, LOCK_FILE_RELATIVE, path, fs } = require('../context');
2+
const { isTerseMode } = require('../output');
23
const { run, runPackageAsset } = require('../core/runtime');
34
const {
45
resolveRepoRoot,
@@ -286,20 +287,37 @@ function finish(rawArgs, defaults = {}) {
286287
let succeeded = 0;
287288
let failed = 0;
288289
let autoCommitted = 0;
290+
const terse = isTerseMode();
289291

290292
for (const candidate of candidates) {
291293
const { branch, baseBranch, worktreePath } = candidate;
292-
console.log(
293-
`[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
294-
);
294+
// In terse mode, defer the "Finishing X -> Y" line until we know whether
295+
// we also need to announce an auto-commit, then emit a single combined
296+
// line per branch. Keep branch + base + worktree path so agents still see
297+
// the load-bearing literals.
298+
if (!terse) {
299+
console.log(
300+
`[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
301+
);
302+
}
295303

296304
try {
297305
let commitState = { changed: false, committed: false };
298306
if (worktreePath) {
299307
commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
300308
}
301309

302-
if (commitState.committed) {
310+
if (terse) {
311+
const suffix = commitState.committed
312+
? ' [auto-committed]'
313+
: (commitState.changed && commitState.dryRun ? ' [dry-run: would auto-commit]' : '');
314+
console.log(
315+
`[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}${suffix}`,
316+
);
317+
if (commitState.committed) {
318+
autoCommitted += 1;
319+
}
320+
} else if (commitState.committed) {
303321
autoCommitted += 1;
304322
console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
305323
} else if (commitState.changed && commitState.dryRun) {

src/output/index.js

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,48 @@ function supportsAnsiColors() {
3232
return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
3333
}
3434

35+
// envTruthy returns true when an env var is set to a truthy-ish string. Used
36+
// by isTerseMode so callers can flip terse / verbose without parsing flags.
37+
function envTruthy(name) {
38+
const value = String(process.env[name] || '').trim().toLowerCase();
39+
return value === '1' || value === 'true' || value === 'yes' || value === 'on';
40+
}
41+
42+
// argvHasFlag scans the current process argv for a literal flag. This avoids
43+
// re-parsing every command's argument table just to learn whether the caller
44+
// asked for verbose / terse output.
45+
function argvHasFlag(flag) {
46+
const argv = process.argv || [];
47+
for (let index = 2; index < argv.length; index += 1) {
48+
if (argv[index] === flag) {
49+
return true;
50+
}
51+
}
52+
return false;
53+
}
54+
55+
// isTerseMode collapses noisy human-friendly narration when stdout is being
56+
// piped (typical for AI coding agents that read CLI output into a conversation
57+
// transcript). Explicit verbose flags / env vars always win so operators can
58+
// recover full output. Decorative blank lines, banners, and "[OK] default
59+
// chosen" confirmations should be gated through this helper; errors, blockers,
60+
// PR URLs, branch names, and file paths must stay visible in both modes.
61+
function isTerseMode() {
62+
if (envTruthy('GUARDEX_VERBOSE')) {
63+
return false;
64+
}
65+
if (argvHasFlag('--verbose')) {
66+
return false;
67+
}
68+
if (envTruthy('GUARDEX_TERSE')) {
69+
return true;
70+
}
71+
if (argvHasFlag('--terse')) {
72+
return true;
73+
}
74+
return !process.stdout.isTTY;
75+
}
76+
3577
function colorize(text, colorCode) {
3678
if (!supportsAnsiColors()) {
3779
return text;
@@ -231,7 +273,9 @@ function getInvokedCliName() {
231273

232274
function printToolLogsSummary(options = {}) {
233275
const invoked = options.invokedBasename || getInvokedCliName();
234-
const compact = Boolean(options.compact);
276+
// Terse mode collapses the full help tree into a single hint line so agents
277+
// reading non-TTY output don't pay for the decorative banners.
278+
const compact = Boolean(options.compact) || isTerseMode();
235279

236280
if (compact) {
237281
const helpLine = `Try '${invoked} help' for commands, or '${invoked} status --verbose' for full service details.`;
@@ -320,6 +364,27 @@ function usage(options = {}) {
320364
const { outsideGitRepo = false } = options;
321365
const invoked = options.invokedBasename || getInvokedCliName();
322366

367+
// In terse mode (default when stdout is non-TTY, e.g. agents piping output),
368+
// drop the long NOTES / VERSION / QUICKSTART / REPO TOGGLE sections and emit
369+
// just usage + command catalog. Errors and the outside-git-repo hint stay so
370+
// agents still see actionable next steps.
371+
if (isTerseMode()) {
372+
const groupedCommandLinesTerse = groupedCommandCatalogLines(' ', {
373+
colorizeLabel: (text) => text,
374+
})
375+
.map((line) => (line == null ? '' : line))
376+
.join('\n');
377+
console.log(`USAGE: ${invoked} <command> [options]
378+
COMMANDS
379+
${groupedCommandLinesTerse}`);
380+
if (outsideGitRepo) {
381+
console.log(
382+
`[${TOOL_NAME}] No git repository detected. Re-run from a repo root or pass --target <path>.`,
383+
);
384+
}
385+
return;
386+
}
387+
323388
const groupedCommandLines = groupedCommandCatalogLines(' ', {
324389
colorizeLabel: (text) => colorize(text, '1;36'),
325390
})
@@ -578,6 +643,7 @@ function printAutoFinishSummary(summary, options = {}) {
578643
module.exports = {
579644
runtimeVersion,
580645
supportsAnsiColors,
646+
isTerseMode,
581647
colorize,
582648
doctorOutputColorCode,
583649
colorizeDoctorOutput,

0 commit comments

Comments
 (0)