Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-18
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Why

AI agents read `gx` output into their context window, so verbose output costs tokens
every turn. gitguardex already wires one token-saving tool into agents (the `rtk`
command-compression prompt part) and ships terse-by-default output, but the
**headroom** context-compression tool is referenced nowhere. Agents working in a
gx-wired repo are never told to compress large `gx` output / logs / dumps, and gx
has no hook to route its own output through a compressor.

## What Changes

Two backward-compatible layers, both graceful when headroom is absent (gitguardex is
a published npm package, so headroom must never be a hard dependency):

- **Advisory** — add a prompt-only `headroom` part to the AI setup checklist
(`gx prompt`), mirroring the existing `rtk` part, plus `compress` / `compression` /
`headroom-mcp` aliases. Add a headroom bullet to the managed AGENTS companion-tooling
block so the guidance propagates into every consumer repo.
- **Runtime** — add `GUARDEX_COMPRESS_CMD` support: when set, gx pipes its large
narrative output (currently the `gx prompt --snippet` block) through the configured
filter. Gated on terse/non-TTY mode + a size threshold; skips machine-readable
(JSON-looking) text; fails open to the original text on any error.

## Impact

- Affected surfaces: `src/context.js` (AI_SETUP_PARTS + aliases), `src/output/index.js`
(new `compressBlock` / `printCompressible` helpers), `src/cli/commands/prompt.js`
(snippet routed through the compressor), `templates/AGENTS.multiagent-safety.md`.
- Default behavior is unchanged: with no `GUARDEX_COMPRESS_CMD` set, output is
byte-for-byte identical to today. `gx prompt --exec` never includes the prompt-only
headroom part. No API/schema changes; new env knob only.
- Risk: low. The compressor runs with `shell:false` argv (no shell interpolation),
a timeout, and a fail-open fallback; machine-readable output is never compressed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## ADDED Requirements

### Requirement: Headroom advisory prompt part
The AI setup prompt machinery SHALL expose a prompt-only `headroom` part that teaches
agents to compress large `gx` output, logs, and dumps through headroom when available,
and SHALL fall back gracefully (the part is advisory text; it imposes no dependency).

#### Scenario: Full prompt includes headroom guidance
- **WHEN** `gx prompt` is run with no part filter
- **THEN** the output includes a "Headroom context compression" section
- **AND** it names `headroom_compress`, `headroom_retrieve`, and `GUARDEX_COMPRESS_CMD`.

#### Scenario: Part is selectable and aliased
- **WHEN** `gx prompt --part headroom` (or the alias `--part compress`) is run
- **THEN** only the headroom slice is printed
- **AND** `gx prompt --list-parts` includes `headroom`.

#### Scenario: Prompt-only part is excluded from exec output
- **WHEN** `gx prompt --exec --part headroom` is run
- **THEN** the command exits non-zero with a "not available with --exec" error
- **AND** `gx prompt --exec` (no filter) omits the headroom part.

### Requirement: GUARDEX_COMPRESS_CMD runtime compression
gx SHALL route large narrative output through an external compressor when
`GUARDEX_COMPRESS_CMD` is set, and SHALL leave output unchanged otherwise.

#### Scenario: Compressor applied when configured
- **WHEN** `GUARDEX_COMPRESS_CMD` is set to a filter and a large narrative block is
emitted to a non-TTY (terse) stream
- **THEN** the block is piped through the filter and the filter output is printed.

#### Scenario: Unchanged by default and fail-open
- **WHEN** `GUARDEX_COMPRESS_CMD` is unset, or the configured command fails, or the
block is machine-readable (JSON) or below the size threshold
- **THEN** the original block is printed byte-for-byte
- **AND** regressions are covered by tests.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Definition of Done

This change is complete only when **all** of the following are true:

- Every checkbox below is checked.
- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff.
- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline.

## Handoff

- Handoff: change=`agent-claude-wire-headroom-token-saving-skills-into-g-2026-06-18-10-50`; branch=`agent/<your-name>/<branch-slug>`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-claude-wire-headroom-token-saving-skills-into-g-2026-06-18-10-50` on branch `agent/<your-name>/<branch-slug>`. Work inside the existing sandbox, review `openspec/changes/agent-claude-wire-headroom-token-saving-skills-into-g-2026-06-18-10-50/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-wire-headroom-token-saving-skills-into-g-2026-06-18-10-50`.
- [x] 1.2 Define normative requirements in `specs/wire-headroom-token-saving-skills-into-gx-output/spec.md`.

## 2. Implementation

- [x] 2.1 Implement scoped behavior changes (headroom advisory part + aliases in `src/context.js`, AGENTS companion bullet, `compressBlock`/`printCompressible` in `src/output/index.js`, snippet wiring in `src/cli/commands/prompt.js`).
- [x] 2.2 Add/update focused regression coverage (`test/prompt.test.js` headroom + snippet-compression; `test/output.test.js` compressBlock unit tests).

## 3. Verification

- [x] 3.1 Run targeted project verification commands (`node --test test/output.test.js test/prompt.test.js` -> 23 pass, 0 fail; module-load sanity OK).
- [x] 3.2 Run `openspec validate agent-claude-wire-headroom-token-saving-skills-into-g-2026-06-18-10-50 --type change --strict` (valid).
- [x] 3.3 Run `openspec validate --specs` (133 passed, 0 failed).

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation.
- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch).
5 changes: 4 additions & 1 deletion src/cli/commands/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ const {
AI_SETUP_PROMPT,
AI_SETUP_COMMANDS,
} = require('../../context');
const { compressBlock } = require('../../output');

function printAgentsSnippet() {
const snippetPath = path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md');
process.stdout.write(fs.readFileSync(snippetPath, 'utf8'));
// Route the large AGENTS narrative through the optional compressor
// (GUARDEX_COMPRESS_CMD). No compressor configured -> unchanged passthrough.
process.stdout.write(compressBlock(fs.readFileSync(snippetPath, 'utf8')));
}

function copyPrompt() {
Expand Down
14 changes: 14 additions & 0 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,9 @@ const AI_SETUP_PART_ALIASES = new Map([
['compact-commands', 'rtk'],
['command-compression', 'rtk'],
['token-commands', 'rtk'],
['compress', 'headroom'],
['compression', 'headroom'],
['headroom-mcp', 'headroom'],
['reviewbot', 'review-bot'],
['forksync', 'fork-sync'],
]);
Expand Down Expand Up @@ -537,6 +540,17 @@ const AI_SETUP_PARTS = [
'Do not wrap machine-readable commands with RTK when code parses stdout (`--porcelain`, `--json`, NUL-delimited output, or exact stdout contracts).',
],
},
{
name: 'headroom',
label: 'Headroom context compression',
promptLines: [
'When the headroom MCP/proxy is available, compress large blobs before they enter context; it is reversible (retrieve restores the original), so prefer it over truncating. Fall back to plain text when headroom is absent.',
'Compress noisy `gx` output, long logs, and large file/diff dumps with headroom_compress; keep PR URLs, branch names, and file paths visible so you can still act on them.',
'Pull a dropped detail back with headroom_retrieve <hash>; audit savings with headroom_stats.',
'Runtime hook: set GUARDEX_COMPRESS_CMD="<filter>" so gx routes its own large narrative output (e.g. `gx prompt --snippet`) through your compressor; unset leaves output byte-for-byte unchanged.',
'Do not compress machine-readable output (`--json`, `--porcelain`, NUL-delimited, exact stdout contracts) or any value you must use verbatim.',
],
},
{
name: 'integrate',
label: 'Integrate',
Expand Down
97 changes: 97 additions & 0 deletions src/output/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const {
cp,
path,
packageJson,
TOOL_NAME,
Expand Down Expand Up @@ -640,6 +641,99 @@ function printAutoFinishSummary(summary, options = {}) {
}
}

// ---------------------------------------------------------------------------
// Optional output compression (headroom / GUARDEX_COMPRESS_CMD)
//
// gitguardex is a published package, so the headroom compressor is never a hard
// dependency. When GUARDEX_COMPRESS_CMD is set to a stdin->stdout filter
// command, gx routes its large narrative output through it before printing so
// the blob costs fewer tokens once it lands in an AI agent's context. The
// default path (env unset) is byte-for-byte identical to plain console.log, and
// every failure mode falls back to the original text — fail-open, mirroring
// headroom's own proxy posture.
//
// Guards: only compress in terse (agent / non-TTY) mode, only blocks at or
// above COMPRESS_MIN_CHARS, and never machine-readable (JSON) payloads whose
// exact stdout other tools parse.
// ---------------------------------------------------------------------------

const COMPRESS_MIN_CHARS = 400;

// resolveCompressCommand parses GUARDEX_COMPRESS_CMD into an argv array (split
// on whitespace, run with shell:false so no shell interpolation). Returns null
// when unset/empty so callers fall straight through to plain output. Commands
// needing args with embedded spaces should be wrapped in a small script.
function resolveCompressCommand(env = process.env) {
const raw = String(env.GUARDEX_COMPRESS_CMD || '').trim();
if (!raw) {
return null;
}
const argv = raw.split(/\s+/).filter(Boolean);
return argv.length > 0 ? argv : null;
}

// looksMachineReadable keeps JSON / structured payloads (status --json, lock
// files, MCP responses) from ever being compressed; those have exact stdout
// contracts downstream parsers depend on. This is a first-character heuristic
// (leading `{` or `[`), which fully covers the current narrative call-sites
// (the markdown AGENTS snippet). A future caller that pipes other
// machine-readable formats (NUL-delimited, TSV, YAML) through compressBlock
// must guard that itself.
function looksMachineReadable(text) {
const head = String(text || '').trimStart()[0];
return head === '{' || head === '[';
}

// compressBlock returns `text` unchanged unless a compressor is configured AND
// we are in terse (agent / non-TTY) mode AND the block is large enough AND it
// is not machine-readable. Any failure (missing binary, non-zero exit, empty
// output, timeout) falls back to the original text. Never throws. Pass
// options.force to bypass the terse-mode gate (used by tests).
function compressBlock(text, options = {}) {
const input = String(text == null ? '' : text);
const env = options.env || process.env;
const argv = resolveCompressCommand(env);
if (!argv) {
return input;
}
if (!options.force && !isTerseMode()) {
return input;
}
if (input.length < COMPRESS_MIN_CHARS) {
return input;
}
if (looksMachineReadable(input)) {
return input;
}
let result;
try {
result = cp.spawnSync(argv[0], argv.slice(1), {
input,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
timeout: Number(env.GUARDEX_COMPRESS_TIMEOUT_MS) || 5000,
});
} catch {
return input;
}
if (!result || result.error) {
return input;
}
// Non-zero exit OR signal-killed (status === null, e.g. timeout/OOM) -> fall
// back, even if the process emitted partial stdout before dying.
if (result.signal || (typeof result.status === 'number' && result.status !== 0)) {
return input;
}
const out = String(result.stdout || '');
return out.trim().length > 0 ? out : input;
}

// printCompressible writes a large narrative block to stdout, compressed when a
// compressor is configured (see compressBlock). Default path === console.log.
function printCompressible(text, options = {}) {
console.log(compressBlock(text, options));
}

module.exports = {
runtimeVersion,
supportsAnsiColors,
Expand All @@ -664,4 +758,7 @@ module.exports = {
detectRecoverableAutoFinishConflict,
summarizeAutoFinishDetail,
printAutoFinishSummary,
resolveCompressCommand,
compressBlock,
printCompressible,
};
1 change: 1 addition & 0 deletions templates/AGENTS.multiagent-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Persist unresolved questions or blockers into `openspec/plan/<plan-slug>/open-qu

- **fff MCP** (file search): prefer for all file search; fall back to `rtk grep`/`rtk find` or `rg`.
- **rtk** (shell compression): wrap noisy discovery (`rtk ls`/`grep`/`find`/`read`), git/gh (`rtk git status`/`gh pr list`), and verification (`rtk tsc`/`lint`/`test`). Do **not** wrap machine-readable commands (`--porcelain`, `--json`, exact stdout contracts).
- **headroom** (context compression): when available, run large `gx` output, long logs, and big file/diff dumps through `headroom_compress` before reasoning over them (reversible — `headroom_retrieve` restores). Or set `GUARDEX_COMPRESS_CMD="<filter>"` so gx routes its own large narrative output through your compressor. Keep PR URLs, branch names, and file paths visible; never compress `--json`/`--porcelain` or values you act on verbatim.
- **OpenSpec**: keep `openspec/changes/<slug>/tasks.md` current during work, not batched. Validate with `openspec validate --specs` before archive.

### Token / context budget
Expand Down
62 changes: 61 additions & 1 deletion test/output.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const { printAutoFinishSummary } = require('../src/output');
const {
printAutoFinishSummary,
compressBlock,
resolveCompressCommand,
} = require('../src/output');

// A large lowercase block so the `tr a-z A-Z` stub visibly transforms it and it
// clears the COMPRESS_MIN_CHARS threshold.
const BIG_BLOCK = 'gx headroom compression block under test. '.repeat(20);

function captureConsoleLogs(run) {
const originalLog = console.log;
Expand Down Expand Up @@ -74,3 +82,55 @@ test('printAutoFinishSummary keeps hidden failure counts explicit when compact o
assert.match(lines[1], /\[fail\] agent\/fail-one: unexpected auth outage/);
assert.match(lines[2], /7 more branch result\(s\) hidden: fail=1, skip=6/);
});

test('resolveCompressCommand returns null when unset and argv when set', () => {
assert.equal(resolveCompressCommand({}), null);
assert.equal(resolveCompressCommand({ GUARDEX_COMPRESS_CMD: ' ' }), null);
assert.deepEqual(resolveCompressCommand({ GUARDEX_COMPRESS_CMD: 'tr a-z A-Z' }), ['tr', 'a-z', 'A-Z']);
});

test('compressBlock passes text through unchanged when no compressor is configured', () => {
assert.equal(compressBlock(BIG_BLOCK, { env: {}, force: true }), BIG_BLOCK);
});

test('compressBlock runs the configured compressor on large blocks', () => {
const out = compressBlock(BIG_BLOCK, { env: { GUARDEX_COMPRESS_CMD: 'tr a-z A-Z' }, force: true });
assert.equal(out, BIG_BLOCK.toUpperCase());
});

test('compressBlock falls back to the original text when the compressor fails', () => {
const out = compressBlock(BIG_BLOCK, {
env: { GUARDEX_COMPRESS_CMD: 'guardex-no-such-binary-zzz' },
force: true,
});
assert.equal(out, BIG_BLOCK);
});

test('compressBlock never compresses machine-readable JSON payloads', () => {
const json = `{"data":"${'x'.repeat(600)}"}`;
const out = compressBlock(json, { env: { GUARDEX_COMPRESS_CMD: 'tr a-z A-Z' }, force: true });
assert.equal(out, json);
});

test('compressBlock skips blocks below the size threshold', () => {
const small = 'short line';
const out = compressBlock(small, { env: { GUARDEX_COMPRESS_CMD: 'tr a-z A-Z' }, force: true });
assert.equal(out, small);
});

test('compressBlock respects the terse-mode gate (skips when verbose)', () => {
const prev = process.env.GUARDEX_VERBOSE;
process.env.GUARDEX_VERBOSE = '1';
try {
// force omitted -> the terse gate (isTerseMode) applies; GUARDEX_VERBOSE
// forces non-terse, so the compressor is skipped even though it is set.
const out = compressBlock(BIG_BLOCK, { env: { GUARDEX_COMPRESS_CMD: 'tr a-z A-Z' } });
assert.equal(out, BIG_BLOCK);
} finally {
if (prev === undefined) {
delete process.env.GUARDEX_VERBOSE;
} else {
process.env.GUARDEX_VERBOSE = prev;
}
}
});
Loading
Loading