<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Ci-Tools on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/ci-tools/</link><description>Recent content in Ci-Tools on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.161.1</generator><language>en-us</language><lastBuildDate>Sun, 24 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/ci-tools/feed.xml" rel="self" type="application/rss+xml"/><item><title>Agent-friendly JSON output for PHP CI tools</title><link>https://jonesrussell.github.io/blog/agent-output-php-ci-tools/</link><pubDate>Sun, 24 May 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/agent-output-php-ci-tools/</guid><category>ai</category><blog:tag>claude-code</blog:tag><blog:tag>php</blog:tag><blog:tag>ci-tools</blog:tag><blog:tag>waaseyaa</blog:tag><description>How the waaseyaa/agent-output package shrinks PHPUnit, PHPStan, and bin/check-* output to compact NDJSON envelopes so AI agents do not drown their context window in CI noise.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p>When an AI agent runs your test suite or a CI gate during an implement-or-review loop, the verbose stdout gets piped straight back into its context window. A full <a href="https://phpunit.de/">PHPUnit</a> run on the <a href="https://github.com/waaseyaa/framework">Waaseyaa framework</a> monorepo is around 12,000 lines. <code>bin/check-package-layers</code> is about 600. Per iteration, per gate. The token cost is real, and it compounds across review cycles. This post walks through <code>waaseyaa/agent-output</code>, a Layer 0 package that shrinks that output to a single NDJSON line for agents while leaving human terminal output completely unchanged.</p>
<h2 id="why-agent-context-windows-hate-ci-output">Why agent context windows hate CI output</h2>
<p>The pattern shows up the moment you let an agent drive your test loop. The agent runs <code>composer test</code>. PHPUnit emits its banner, then a dot per test, then a footer summary, then optionally a slow-test report. None of that helps the agent. It needs three things: did the run pass, what failed, where. Everything else is noise that displaces real signal.</p>
<p>The same is true for <code>bin/check-package-layers</code>, <code>bin/check-phpstan</code>, <code>tools/drift-detector.sh</code>, and friends. Each one is a CI gate that the agent already understands at the contract level. The full human-readable output exists to help a person scan and react. An agent does not need any of it.</p>
<h2 id="what-the-package-does">What the package does</h2>
<p><code>waaseyaa/agent-output</code> is a single-purpose Layer 0 package (no <code>waaseyaa/*</code> runtime deps, installable standalone). It does three things:</p>
<ol>
<li><strong>Detects an agent runtime</strong> from a list of well-known env vars (<code>CLAUDE_CODE</code>, <code>CURSOR_AGENT</code>, and the rest), extensible.</li>
<li><strong>Provides a <code>FormatterInterface</code></strong> and first-party formatters for PHPUnit, Pest, PHPStan, the <code>bin/check-*</code> CI gates, and the drift detector.</li>
<li><strong>Honors three activation triggers</strong> per command: an <code>--output=json</code> flag, a <code>WAASEYAA_OUTPUT=json</code> env var, or auto-activation when an agent env var is set.</li>
</ol>
<p>When none of those triggers apply, the affected command emits exactly the human output it always did. No JSON fields leak, no exit codes change.</p>
<h2 id="three-ways-to-flip-a-tool-into-agent-mode">Three ways to flip a tool into agent mode</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>bin/check-package-layers --output<span style="color:#f92672">=</span>json
</span></span><span style="display:flex;"><span>WAASEYAA_OUTPUT<span style="color:#f92672">=</span>json bin/check-package-layers
</span></span><span style="display:flex;"><span>CLAUDE_CODE<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> bin/check-package-layers
</span></span></code></pre></div><p>The first is explicit per-invocation. The second sets it for the shell. The third is what happens automatically when Claude Code (or another supported agent) drives your terminal — you do not have to wire anything up; the auto-detection kicks in.</p>
<h2 id="coverage">Coverage</h2>
<p>Here is the full set of tools the package now covers, taken verbatim from the package README:</p>
<table>
  <thead>
      <tr>
          <th>Tool</th>
          <th>Trigger</th>
          <th>Formatter</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>bin/check-package-layers</code></td>
          <td><code>--output=json</code> / env</td>
          <td><code>PackageLayersFormatter</code></td>
      </tr>
      <tr>
          <td><code>bin/check-dead-code</code></td>
          <td><code>--output=json</code> / env</td>
          <td><code>DeadCodeFormatter</code></td>
      </tr>
      <tr>
          <td><code>bin/check-getquery-bindings</code></td>
          <td><code>--output=json</code> / env</td>
          <td><code>GetQueryBindingsFormatter</code></td>
      </tr>
      <tr>
          <td><code>bin/check-composer-policy</code></td>
          <td><code>--output=json</code> / env</td>
          <td><code>ComposerPolicyFormatter</code></td>
      </tr>
      <tr>
          <td><code>bin/check-phpstan</code></td>
          <td><code>--output=json</code> / env</td>
          <td><code>PhpStanFormatter</code></td>
      </tr>
      <tr>
          <td><code>tools/drift-detector.sh</code></td>
          <td><code>--output=json</code> / env</td>
          <td><code>DriftDetectorFormatter</code></td>
      </tr>
      <tr>
          <td><code>vendor/bin/phpunit</code></td>
          <td><code>WAASEYAA_OUTPUT=json</code> (PHPUnit does not surface custom CLI flags)</td>
          <td><code>PhpUnitFormatter</code> via <code>AgentOutputPhpUnitExtension</code></td>
      </tr>
  </tbody>
</table>
<p>Five <code>bin/check-*</code> scripts, a drift detector, and PHPUnit. Each one emits an NDJSON envelope through a formatter dedicated to that tool&rsquo;s domain.</p>
<h2 id="phpunit-is-the-awkward-one">PHPUnit is the awkward one</h2>
<p>PHPUnit&rsquo;s extension API does not surface custom CLI flags. There is no clean way to add <code>--output=json</code> and have PHPUnit pass it to your extension. So the env var is the canonical trigger, and the package ships a PHPUnit 10 extension that registers six event subscribers (passed, failed, errored, marked-incomplete, skipped, execution-finished) over a shared run-state object:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">PhpUnitRunState</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $passed <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $failed <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#a6e22e">int</span> $skipped <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">/** @var list&lt;array{test: string, file: string, line: int, message: string}&gt; */</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">array</span> $failures <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That class lives in its own file rather than as an anonymous shape inside the extension, so PHPStan can type-check the field accesses without inferring <code>mixed</code> through anonymous classes. A small thing, but it is the kind of detail that decides whether a package&rsquo;s own lint suite stays green.</p>
<p>The extension itself is a no-op when <code>WAASEYAA_OUTPUT</code> is not <code>json</code> — zero overhead in human mode. When it is, the envelope is printed at <code>TestRunner\ExecutionFinished</code> with a leading newline so it lands on its own trailing line. Agent consumers read the file line-by-line and parse the line that starts with <code>{&quot;tool&quot;:&quot;phpunit&quot;</code>.</p>
<h2 id="what-the-numbers-say">What the numbers say</h2>
<p>WP06 of the mission was an empirical token-reduction smoke test against the original NFR. The headline result, measured on <code>packages/foundation/tests/Unit --no-coverage</code>:</p>
<ul>
<li><strong>Standard PHPUnit output:</strong> 2,209 bytes</li>
<li><strong>Agent envelope (NDJSON line only):</strong> 117 bytes</li>
<li><strong>Reduction:</strong> 94.70%</li>
</ul>
<p>The threshold was ≥90%. The pattern delivers. And that number understates the savings on a full monorepo run, where the human output runs in the thousands of lines and the envelope stays a single line.</p>
<h2 id="why-not-just-use-laravel-pao">Why not just use Laravel PAO?</h2>
<p>The pattern was lifted from Laravel PAO (released around May 2026), but the package is framework-native for two reasons. First, PAO does not cover the custom CI gates the Waaseyaa monorepo runs as hard gates (<code>bin/check-package-layers</code> and the rest). Second, the formatters need to live alongside the gate scripts so the contract between script and envelope shape can evolve in the same PR — third-party packaging would have made that coupling awkward.</p>
<p>The package is also a Layer 0 dependency, which means anyone outside the Waaseyaa monorepo can install just <code>waaseyaa/agent-output</code> and reuse the formatter interface for their own tools. The detection logic and envelope contract travel; the bin/check-* wrappers stay in the framework where they belong.</p>
<h2 id="try-it-in-your-own-monorepo">Try it in your own monorepo</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>composer require waaseyaa/agent-output
</span></span></code></pre></div><p>Then either pass <code>--output=json</code> to any supported script, set <code>WAASEYAA_OUTPUT=json</code> in your shell, or run under an agent that sets <code>CLAUDE_CODE=1</code>. For PHPUnit specifically, register the extension in <code>phpunit.xml.dist</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#f92672">&lt;extensions&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;bootstrap</span> <span style="color:#a6e22e">class=</span><span style="color:#e6db74">&#34;Waaseyaa\AgentOutput\Listener\AgentOutputPhpUnitExtension&#34;</span><span style="color:#f92672">/&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/extensions&gt;</span>
</span></span></code></pre></div><p>The extension self-disables when <code>WAASEYAA_OUTPUT</code> is not set to <code>json</code>, so registering it does not change human-mode output.</p>
<p>For the full envelope schema, formatter contract, and a guide for writing third-party formatters, see <code>docs/specs/agent-output.md</code> in the framework repo.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>