The output policy
One function decides where output is going and how much colour it may carry: roundel/policy's outputMode() and colorLevel(), and roundel/terminal's interactive(). The packages that draw ask them rather than reading the process themselves.
What it is
Two subpaths of roundel answer the questions every package that draws has to ask before it writes a byte:
roundel/policy: which mode this run is in (outputMode), and how much colour it may use (colorLevel).roundel/terminal: is anybody there to type (interactive), and can the terminal draw a tick (unicode).
All four are pure functions of a runtime you pass in (env, isTTY, and for colour argv),
never of process. The same input always gives the same answer, so a test passes a literal
and a harness can ask the question a program asked.
Why it exists
A spinner, a prompt and the help text each have to decide whether the terminal is a
terminal. When each package decides its own way, they disagree about the same run: one
animates into a CI log while another has already gone plain. roundel/policy's own
documentation cites that failure (clack #286) as the reason it exists: every package in the
family that draws asks here instead of reading isTTY, NO_COLOR, FORCE_COLOR, CI,
CLI_ACCESSIBLE or a --color flag itself.
The exceptions are named, not tolerated: flagstaff/ora's isInteractive, caique/clack's
isCI, burgee/commander's colour check and paratext's hyperlink detection each reproduce
their incumbent's own detection, because the incumbent's suite grades exactly that. Each is a
row, with its reason, in the KNOWN table of
inline-implementation-lock.test.ts.
The modes
outputMode(rt, { json }) returns one of five modes. The first row that matches wins:
| Order | Mode | Chosen when |
|---|---|---|
| 1 | json | the caller passed { json: true }: the run was asked for --json |
| 2 | accessible | CLI_ACCESSIBLE is set, terminal or not |
| 3 | tty | stdout is a terminal |
| 4 | ci | CI is set and stdout is not a terminal |
| 5 | pipe | anything else |
An empty variable is not set: CI= and CLI_ACCESSIBLE= are the same as leaving them out,
the convention NO_COLOR established. --json is passed in rather than read from argv,
because whether a run asked for structured output is the argument parser's knowledge.
There is no agent mode. An agent reading a pipe is pipe, an agent that asked for
--json is json, and whether an agent can be asked something is a separate question,
interactive(), below.
Pinned by outputMode — R1, first match wins and an empty variable is not set in
policy.test.ts.
The mode is not the colour
The mode decides redraws: whether a component animates in place (tty) or prints its
static projection once per change. It never decides the
colour level, which colorLevel(rt, { json }) answers from the user's instruction first and
detection second, in this order:
--json,NO_COLOR, or accessible with nothing asked → 0. Structured output carries no escapes. A non-emptyNO_COLORbeats every instruction below, includingFORCE_COLOR=3and--color.CLI_ACCESSIBLEdefaults to 0, because colour is noise to a screen reader, but an explicit ask still wins over it.- An exact instruction → that level.
FORCE_COLOR=0(orfalse) is read before any flag, soFORCE_COLOR=0 --color=256is off: an explicit "off" is never undone by a level flag. After that the--colorflags inargvoutrank a numericFORCE_COLOR:--no-color,--no-colors,--color=false|neverare 0;--color=256is 2;--color=16m|full|truecoloris 3. A numericFORCE_COLORis that level, clamped to 3. Only flags before a--terminator count, and an unrecognised value (--color=lots) is no instruction. - Azure Pipelines (
TF_BUILDandAGENT_NAME) → 1, the one runner read above the pipe check. - A pipe nobody asked to colour → 0.
TERM=dumb→ the floor: 1 if something asked for colour (FORCE_COLOR=true,--color), else 0.- Detection. With
CIpresent, the CI vendor's level (GITHUB_ACTIONS,GITEA_ACTIONS,CIRCLECIare 3;TRAVIS,APPVEYOR,GITLAB_CI,BUILDKITE,DRONEand Codeship are 1; any other runner 0); otherwiseCOLORTERM=truecoloris 3, aTERMending in-256or-256coloris 2, and a knownTERMor anyCOLORTERMis 1. An ask for colour without a level (FORCE_COLOR=true,--color) never goes below 1.
The levels are chalk's: 0 none, 1 sixteen colours, 2 256 colours, 3 truecolor. With no
instruction the order is supports-color's, which chalk's own level.js asserts, so the
family and chalk agree about a bare pipe. Each rule is a describe block in
policy.test.ts:
R2 revised 2026-09-08: FORCE_COLOR is the user’s instruction, in any mode,
the --color flags, when the caller hands over argv,
an explicit “colour off” is never overridden into colour on,
accessible mode is a pipe, not a terminal (R2) and
the CI vendor table, once the run has asked for colour.
Colour levels on roundel's site
has the full table.
Is anybody there to type?
interactive(rt) is true when FORCE_TTY=1, and otherwise only with a terminal on stdin,
no non-empty CI, and none of the agent variables AI_AGENT, CLAUDECODE, CURSOR_AGENT,
CODEX_THREAD_ID, GEMINI_CLI (exported as AGENTS). An agent may have a terminal; what it
does not have is a person. Pinned by the interactive block of
terminal.test.ts,
including names CLAUDECODE, which is the case that hung an agent.
Run it
colorLevel and outputMode read a runtime; this one describes the real process:
import { colorLevel, outputMode } from 'roundel/policy';
import { interactive } from 'roundel/terminal';
const json = process.argv.includes('--json');
const rt = {
env: process.env,
argv: process.argv.slice(2),
isTTY: { stdout: process.stdout.isTTY === true, stdin: process.stdin.isTTY === true },
};
console.log(`mode=${outputMode(rt, { json })} level=${colorLevel(rt, { json })} interactive=${interactive(rt)}`);Piped, as every command below runs, with stdin closed:
mode=pipe level=0 interactive=falsemode=ci level=0 interactive=falsemode=accessible level=2 interactive=falsemode=pipe level=0 interactive=falsemode=pipe level=0 interactive=falsemode=ci level=3 interactive=falsemode=json level=0 interactive=falsemode=pipe level=0 interactive=trueHow the family consults it
Only packages that compose may import roundel (the family has the rule), so the policy is read by the three packages that draw, and by nothing below them:
| Package | Asks | For |
|---|---|---|
| flagstaff | outputMode | once per hoist(): tty animates, json writes NDJSON to stderr, every other mode prints the static projection |
| flagstaff | unicode | flagstaff/ora's symbols, as is-unicode-supported decides them |
| caique | interactive | caique/decide: nobody there means refuse and name the flag, never wait |
| caique | unicode | caique/inquirer's glyphs |
| burgee | colorLevel | whether help is coloured (help.ts's colorFor) |
burgee's handler context is the one place the family answers "interactive" differently.
ctx.interactive comes from burgee's own detectAgent (agent.ts): FORCE_TTY=1, or
stdout is a terminal and no agent variable is set. It probes the same five variables as
AGENTS, but it reads stdout rather than stdin and does not read CI. Pinned by
agent.test.ts.
The leaves take a runtime slice of the same shape and decide their own narrower question from it: paratext whether a terminal escape is supported, for instance. They cannot import roundel, because a leaf depends on nothing in the family.
Where the rules live
- Spec: roundel R1, R2 and R12.
- API:
roundel/policy,roundel/terminal. - Guides: The output policy and Colour levels on roundel's site.
The family and its layers
One job per package: six leaves that depend on nothing, three packages that compose them, dependencies that point one way, nothing installed from outside the family, and the locks that hold each rule.
The static projection
Every live widget and every terminal capability in the family has a plain-text form for pipes, CI and screen readers — and one without it is refused when it is registered, not discovered when it prints.