v0.42.15.0 fix: decouple CLI primary output from process.stdout.isTTY (#1784) (#1806)

* feat(eval): cycle-default — single source of truth for TTY/non-TTY cycle count (#1784)

* fix(jobs): decouple 'jobs watch' format (--json) from loop (--follow); non-TTY prints one human snapshot (#1784)

* fix(reindex): human cost-refusal unless --json; spend guardrail unchanged (#1784)

* fix(eval): annotate non-interactive cycle/budget defaults; runner core TTY-agnostic (#1784)

* chore: bump version and changelog (v0.42.15.0)

Decouple CLI primary output from process.stdout.isTTY (#1784): human by
default, JSON only with --json; jobs watch non-TTY one-shots; eval banners
annotate non-interactive defaults; reindex-code refusal is human unless --json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: CLAUDE.md Key Files notes for v0.42.15.0 isTTY-output decoupling (#1784)

Annotate jobs.ts (jobs watch format/loop split + resolveWatchMode + the new
cycle-default.ts), reindex-code.ts (human cost-refusal), and eval-cross-modal.ts
(cycle/budget banner annotations). Regenerate llms-full.txt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-03 07:09:20 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 1036f8f752
commit 488f89e0dc
18 changed files with 535 additions and 43 deletions
+61
View File
@@ -0,0 +1,61 @@
/**
* v0.42.11.0 (#1784) — non-TTY output contract E2E (the `cmd </dev/null` smoke
* test the issue asked for).
*
* `jobs watch` is the command whose PRIMARY output was gated on isTTY: non-TTY
* callers (subagent / pipe / cron) got JSON-only with no way to a human view,
* and the loop ran forever. The fix decouples FORMAT (--json) from LOOP
* (--follow, default=isTTY), so a non-TTY run prints ONE human snapshot and
* exits.
*
* This drives the REAL binary in a non-TTY child (stdin ignored, stdout piped →
* `process.stdout.isTTY` is false), so it exercises the exact path subagents and
* pipes hit. Hermetic PGLite brain in a temp HOME; no Postgres, no API keys.
*
* Serial because it spawns subprocesses against a temp brain and PGLite is a
* single-writer engine.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { execFileSync } from 'child_process';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
describe('non-TTY output contract: jobs watch (#1784)', () => {
let home: string;
let env: NodeJS.ProcessEnv;
beforeAll(() => {
home = mkdtempSync(join(tmpdir(), 'gbrain-nontty-e2e-'));
env = { ...process.env, GBRAIN_HOME: home };
// Fresh local PGLite brain so `jobs watch` has an engine to read.
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
cwd: process.cwd(), env, stdio: 'ignore',
});
}, 60_000);
afterAll(() => {
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
});
test('non-TTY, no flags → ONE human snapshot, non-empty, not JSON, exit 0', () => {
// stdin 'ignore' + stdout 'pipe' makes the child non-TTY on both ends.
const out = execFileSync('bun', ['run', 'src/cli.ts', 'jobs', 'watch'], {
cwd: process.cwd(), env, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 30_000,
});
expect(out.length).toBeGreaterThan(0); // the bug was (no output)
expect(out).toContain('gbrain jobs watch'); // human renderer header
expect(out).toContain('Queue'); // human panel
expect(out.trimStart().startsWith('{')).toBe(false); // NOT JSON by default
}, 35_000);
test('non-TTY, --json → ONE JSON snapshot, parses, exit 0', () => {
const out = execFileSync('bun', ['run', 'src/cli.ts', 'jobs', 'watch', '--json'], {
cwd: process.cwd(), env, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 30_000,
});
const firstLine = out.split('\n').find(l => l.trim().length > 0) ?? '';
const parsed = JSON.parse(firstLine);
expect(parsed.event).toBe('jobs.watch.snapshot');
expect(parsed.queue_health).toBeDefined();
}, 35_000);
});
@@ -0,0 +1,37 @@
/**
* v0.42.11.0 (#1784) — suspected-contradictions budget-default detection.
*
* The TTY-derived budget default ($5 TTY / $1 non-TTY) is overwritten in-place,
* so the banner needs an explicit `budgetUsdExplicit` flag to know whether to
* annotate the value as the silent non-interactive default. These run non-TTY
* (bun test pipes stdout), so the default resolves to $1.
*/
import { describe, test, expect } from 'bun:test';
import { parseFlags } from '../src/commands/eval-suspected-contradictions.ts';
describe('parseFlags budgetUsdExplicit', () => {
test('no --budget-usd → non-TTY default $1, not flagged explicit', () => {
const f = parseFlags([]);
expect(f.budgetUsd).toBe(1); // bun test is non-TTY
expect(f.budgetUsdExplicit).toBe(false);
});
test('--budget-usd 3 → value 3, flagged explicit', () => {
const f = parseFlags(['--budget-usd', '3']);
expect(f.budgetUsd).toBe(3);
expect(f.budgetUsdExplicit).toBe(true);
});
test('explicit value equal to the default still counts as explicit', () => {
const f = parseFlags(['--budget-usd', '1']);
expect(f.budgetUsd).toBe(1);
expect(f.budgetUsdExplicit).toBe(true);
});
test('subcommand positional does not break flag parsing', () => {
const f = parseFlags(['run', '--budget-usd', '2']);
expect(f.sub).toBe('run');
expect(f.budgetUsd).toBe(2);
expect(f.budgetUsdExplicit).toBe(true);
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* v0.42.11.0 (#1784) — cycle-default resolver unit tests.
*
* Pins the 4 branches of resolveCycleDefault + the banner-suffix contract.
* Pure functions, no DB, no env mutation.
*/
import { describe, test, expect } from 'bun:test';
import {
resolveCycleDefault,
cycleDefaultSuffix,
DEFAULT_CYCLES_TTY,
DEFAULT_CYCLES_NONTTY,
} from '../../src/core/eval/cycle-default.ts';
describe('resolveCycleDefault', () => {
test('explicit value wins (TTY) — no annotation', () => {
const r = resolveCycleDefault(5, true);
expect(r).toEqual({ cycles: 5, usedNonTtyDefault: false });
});
test('explicit value wins (non-TTY) — no annotation', () => {
const r = resolveCycleDefault(5, false);
expect(r).toEqual({ cycles: 5, usedNonTtyDefault: false });
});
test('TTY default → 3, no annotation', () => {
const r = resolveCycleDefault(undefined, true);
expect(r).toEqual({ cycles: DEFAULT_CYCLES_TTY, usedNonTtyDefault: false });
expect(r.cycles).toBe(3);
});
test('non-TTY default → 1, ANNOTATED (the silent-degradation case)', () => {
const r = resolveCycleDefault(undefined, false);
expect(r).toEqual({ cycles: DEFAULT_CYCLES_NONTTY, usedNonTtyDefault: true });
expect(r.cycles).toBe(1);
});
test('explicit 1 in non-TTY is NOT flagged as the default (user asked for it)', () => {
const r = resolveCycleDefault(1, false);
expect(r.usedNonTtyDefault).toBe(false);
});
});
describe('cycleDefaultSuffix', () => {
test('empty unless the non-TTY default was applied', () => {
expect(cycleDefaultSuffix({ cycles: 3, usedNonTtyDefault: false })).toBe('');
expect(cycleDefaultSuffix({ cycles: 5, usedNonTtyDefault: false })).toBe('');
});
test('names the --cycles override when annotated', () => {
const s = cycleDefaultSuffix({ cycles: 1, usedNonTtyDefault: true });
expect(s).toContain('non-interactive default');
expect(s).toContain('--cycles');
});
test('round-trips with resolveCycleDefault non-TTY path', () => {
expect(cycleDefaultSuffix(resolveCycleDefault(undefined, false))).not.toBe('');
expect(cycleDefaultSuffix(resolveCycleDefault(undefined, true))).toBe('');
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* v0.42.15.0 (#1784) — jobs-watch format × loop matrix.
*
* Pins resolveWatchMode so the TTY-gating contract can't regress. The bug this
* guards: TTY + --json used to loop forever (follow defaulted to isTTY); the
* documented matrix says --json one-shots unless --follow. Caught by codex in
* the pre-landing review; the e2e missed it (non-TTY only).
*/
import { describe, test, expect } from 'bun:test';
import { resolveWatchMode } from '../src/commands/jobs-watch.ts';
describe('resolveWatchMode — format × loop matrix', () => {
test('TTY, no flags → live dashboard (json off, follow on, ansi on)', () => {
expect(resolveWatchMode({}, true)).toEqual({ json: false, follow: true, useAnsiDashboard: true });
});
test('non-TTY, no flags → one human snapshot (the bug fix: not JSON, one-shot)', () => {
expect(resolveWatchMode({}, false)).toEqual({ json: false, follow: false, useAnsiDashboard: false });
});
test('TTY + --json → ONE JSON snapshot, NOT a forever loop (codex finding)', () => {
expect(resolveWatchMode({ json: true }, true)).toEqual({ json: true, follow: false, useAnsiDashboard: false });
});
test('non-TTY + --json → one JSON snapshot', () => {
expect(resolveWatchMode({ json: true }, false)).toEqual({ json: true, follow: false, useAnsiDashboard: false });
});
test('TTY + --follow → live dashboard loops', () => {
expect(resolveWatchMode({ follow: true }, true)).toEqual({ json: false, follow: true, useAnsiDashboard: true });
});
test('non-TTY + --follow → plain human stream (loops, no ansi)', () => {
expect(resolveWatchMode({ follow: true }, false)).toEqual({ json: false, follow: true, useAnsiDashboard: false });
});
test('--json --follow → JSONL stream, never the ansi dashboard', () => {
expect(resolveWatchMode({ json: true, follow: true }, true)).toEqual({ json: true, follow: true, useAnsiDashboard: false });
});
test('explicit --follow false on a TTY → one-shot (override wins)', () => {
expect(resolveWatchMode({ follow: false }, true)).toEqual({ json: false, follow: false, useAnsiDashboard: false });
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* v0.42.11.0 (#1784) — reindex-code cost-refusal format unit tests.
*
* The cost gate still REFUSES to spend non-interactively without --yes (exit 2,
* tested at the CLI layer). This pins the separate FORMAT axis: JSON only when
* --json is explicit; otherwise a human refusal on stderr. Pure function — no
* brain, no real cost preview.
*/
import { describe, test, expect } from 'bun:test';
import { buildCostRefusal } from '../src/commands/reindex-code.ts';
const PREVIEW_MSG = 'reindex-code: 42 code page(s), ~12,345 tokens, est. $0.12 on voyage:voyage-code-3.';
describe('buildCostRefusal', () => {
test('--json → machine-readable envelope on stdout, nothing on stderr', () => {
const r = buildCostRefusal({
json: true,
previewMsg: PREVIEW_MSG,
preview: { totalPages: 42, totalTokens: 12345 },
costUsd: 0.12,
model: 'voyage:voyage-code-3',
});
expect(r.stderr).toBeUndefined();
expect(typeof r.stdout).toBe('string');
const parsed = JSON.parse(r.stdout!);
expect(parsed.error).toBeDefined();
// The structured refusal must still carry the confirmation code + context.
expect(JSON.stringify(parsed.error)).toContain('cost_preview_requires_yes');
expect(parsed.costUsd).toBe(0.12);
expect(parsed.model).toBe('voyage:voyage-code-3');
expect(parsed.preview).toEqual({ totalPages: 42, totalTokens: 12345 });
});
test('no --json → human refusal on stderr, nothing on stdout (not JSON)', () => {
const r = buildCostRefusal({
json: false,
previewMsg: PREVIEW_MSG,
preview: {},
costUsd: 0.12,
model: 'voyage:voyage-code-3',
});
expect(r.stdout).toBeUndefined();
expect(typeof r.stderr).toBe('string');
// Human text: includes the preview, the refusal reason, and both escape hatches.
expect(r.stderr).toContain(PREVIEW_MSG);
expect(r.stderr).toContain('Refusing to re-embed');
expect(r.stderr).toContain('--yes');
expect(r.stderr).toContain('--dry-run');
// Must NOT be a JSON envelope.
expect(r.stderr!.trimStart().startsWith('{')).toBe(false);
});
});