<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>CLI - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/cli/</link><description>CLI - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Thu, 23 Jul 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>CLI - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/cli/</link></image><atom:link href="https://tracyatteberry.com/tags/cli/" rel="self" type="application/rss+xml"/><item><title>Everything in place</title><link>https://tracyatteberry.com/posts/mise_pitch/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/mise_pitch/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mise_pitch/mise_en_place.jpg" referrerpolicy="no-referrer">
            </div><p>Not long ago <a href="/posts/process_management/" rel="">I wrote</a> about my search
for a process (as in daemons) management tool to manage webservers, build
watch processes, etc. across many different projects. I was still undecided
at that point, but have since landed on <a href="https://pitchfork.jdx.dev" target="_blank" rel="noopener noreffer ">Pitchfork</a>.
It does a bit more than I need and it is well-maintained.
See that previous post for a discussion of that and other options that I
considered.</p>
<p>Having settled on Pitchfork for process management, I next needed to choose
a task runner.
The contenders were: make, just, mise, and go-task/task.
I&rsquo;m not going to do a detailed breakdown, but here&rsquo;s <a href="https://mehdihadeli.com/blog/task-runners-comparison-2026" target="_blank" rel="noopener noreffer ">a post from another
site</a> that does.
It turns out I came to the same decision they did, for mostly the same
reasons. The winner was <a href="https://mise.jdx.dev/" target="_blank" rel="noopener noreffer ">Mise</a>.</p>
<p>You may notice that both tools, Pitchfork and Mise, come from the same
developer. This is a bit of a risk. If the developer (Jeff Dickey) decides
they&rsquo;re no longer interested in maintaining these projects, then I&rsquo;m
suddenly looking for two tool replacements instead of one. But right now,
looking at Jeff&rsquo;s bio, income from his sponsors, support from his employer,
etc., I&rsquo;m really not too concerned.</p>
<p>I picked the top 19 most active repos that I have and migrated all of them
to Pitchfork and Mise. Having that consistency across repos has made
context switching much easier. It&rsquo;s been great so far.</p>
<p>Around the same time, I also began using a few new
<a href="https://github.com/grymoire7/skills" target="_blank" rel="noopener noreffer ">skills</a>. In addition to Pitchfork and
Mise skills, I added a markdown formatter and my own writing skill. The
writing skill comes with an LLM cliche linter tool as well. During the same
migration, I ran the markdown formatter over all my READMEs, so now they&rsquo;re
easier for me to scan and find things in too.</p>
<p>I&rsquo;ll add just a bit of brutal honesty here at the end in case you made it this
far. Whenever I have a little extra time, or I&rsquo;m just procrastinating on
something, I tend to either a) tidy up, or b) try to improve the process.
That was exactly the inspiration for this migration. Cheers!</p>
]]></description></item><item><title>Reviewing project process management tools</title><link>https://tracyatteberry.com/posts/process_management/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/process_management/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/process_management/watermelon_pitchfork.jpg" referrerpolicy="no-referrer">
            </div><h2 id="the-inertia-problem">The inertia problem</h2>
<p>When I start working on a project, I open a terminal and start a few processes.
The web server. A background job worker. The asset watcher that recompiles my
JavaScript. Maybe a locally-running database service. Each one might get its
own terminal tab, and each tab has a command I have to remember, or dig out of
a README, or other docs.</p>
<p>Since I work across multiple projects in different languages, with different
stacks, this gets expensive fast. Not expensive in terms of CPU or memory, but
in terms of mental overhead. Every project has its own conventions: <code>make dev</code>,
<code>./bin/dev</code>, <code>docker compose up</code>, or something else entirely. You spend more
time than you&rsquo;d like just overcoming the inertia to get back to a running state
every time you switch projects.</p>
<p>A process manager gives you a single command to start everything your project
needs. And if you can standardize on one tool across all your projects (🤞), you
eliminate that context-switching tax entirely: <code>cd project &amp;&amp; [tool] start</code>
works everywhere, every time.</p>
<p>That&rsquo;s the goal, so let&rsquo;s look at the options. (Or just scroll down to the
comparison table and the verdict if you want to skip the narrative.)</p>
<p>But before I dive in, it&rsquo;s worth being clear about what I&rsquo;m trying to solve for
here. Most frameworks give you ways to run individual commands natively: <code>npm run dev</code>, <code>mix phx.server</code>, <code>bundle exec rails server</code>. Those are great and you
should use them. Your Procfile entries will often call them directly. A process
manager sits on top of those commands and coordinates them, starting all your
services at once and multiplexing their output into a single view. The two
approaches are complementary, not competing.</p>
<p>There&rsquo;s also a related but separate problem. Task runners like Make, Rake,
Just, and npm scripts handle short-lived commands like running database
migrations, executing tests, or triggering builds. You will still reach for
those tools for that kind of work, and there&rsquo;s a whole separate conversation to
be had about achieving consistency there across projects. This post is focused
specifically on the process management side: starting and keeping multiple
long-lived development services running together.</p>
<h2 id="it-all-started-with-a-procfile">It all started with a Procfile</h2>
<p>In 2011, David Dollar released <a href="https://github.com/ddollar/foreman" target="_blank" rel="noopener noreffer ">Foreman</a>, a Ruby gem for
managing multiple processes during development. The central idea was the <code>Procfile</code>: a plain text
file that declares your process types, one per line.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><pre tabindex="0"><code>web: bin/rails server
worker: bundle exec sidekiq
assets: npm run watch</code></pre></div>
<p>Heroku adopted the format and popularized it. Today it&rsquo;s the closest thing the development world
has to a standard for declaring process types. Ports of Foreman exist for Python
(<a href="https://github.com/nickstenning/honcho" target="_blank" rel="noopener noreffer ">Honcho</a>), Go
(<a href="https://github.com/mattn/goreman" target="_blank" rel="noopener noreffer ">goreman</a>, <a href="https://github.com/ddollar/forego" target="_blank" rel="noopener noreffer ">forego</a>), and
more. Those ports largely replicate the original feature set, so I won&rsquo;t review them separately,
but the ecosystem shows how durable the Procfile idea has been.</p>
<p>What followed were tools that kept the Procfile format but went well beyond what Foreman
originally offered. Here&rsquo;s where things stand today.</p>
<h2 id="the-tools">The tools</h2>
<h3 id="foreman">Foreman</h3>
<p>Foreman is where it all started for me, and it still does its job. Install the gem, drop a Procfile
in your project, and run <code>foreman start</code>. It multiplexes the output of all your processes into a
single stream, prefixed with the process name and a timestamp.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gem install foreman
</span></span><span class="line"><span class="cl">foreman start</span></span></code></pre></div></div>
<p>One unique feature is that Foreman can export your Procfile to init system formats like
systemd, upstart, launchd, and runit. If you want to manage production processes the same way
you manage development ones, that export path is a nice convenience. None of the other tools
in this review offer it.</p>
<p><strong>Pros:</strong> Mature and battle-tested. Dead simple to adopt. Procfile export to init systems is
unique among these tools. Wide adoption means good documentation and community support.</p>
<p><strong>Cons:</strong> It&rsquo;s a Ruby gem, which adds a runtime dependency for non-Ruby projects. The bigger
issue is how it handles process output. Processes detect they are not writing to a real terminal
and suppress their colored output. If you&rsquo;ve ever run <code>rails server</code> through Foreman and wondered
why the logs look flat and grey, that&rsquo;s why.</p>
<hr>
<h3 id="hivemind">Hivemind</h3>
<p><a href="https://github.com/DarthSim/hivemind" target="_blank" rel="noopener noreffer ">Hivemind</a> was built by the team at Evil Martians to fix
exactly that problem. It uses a PTY (pseudo-terminal) to capture process output, which means
processes think they are writing to a real terminal. So colors work and log output behaves the way
you&rsquo;d expect.</p>
<p>It&rsquo;s a standalone Go binary with no runtime dependencies beyond the binary itself. Install it
with Homebrew or download the binary directly.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">brew install hivemind
</span></span><span class="line"><span class="cl">hivemind</span></span></code></pre></div></div>
<p>Hivemind is deliberately minimal. It reads your Procfile, starts your processes, and gets out of
the way. The README itself will tell you: if you want more features, check out Overmind.</p>
<p><strong>Pros:</strong> Standalone binary with no language runtime required. Fixes the color output problem.
Extremely easy to adopt, with the same Procfile format and simpler installation than Foreman for
non-Ruby projects.</p>
<p><strong>Cons:</strong> No interactive process management. If a process crashes, the whole group goes down.
There&rsquo;s no way to restart a single process without restarting everything.</p>
<hr>
<h3 id="overmind">Overmind</h3>
<p><a href="https://github.com/DarthSim/overmind" target="_blank" rel="noopener noreffer ">Overmind</a> is Hivemind&rsquo;s more capable sibling, also from
Evil Martians. The key difference is that Overmind runs your processes inside a tmux session,
which gives it capabilities no other Procfile tool matches.</p>
<p>You can connect directly to any process and interact with it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">overmind connect web</span></span></code></pre></div></div>
<p>You can restart a single process without touching the others:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">overmind restart sidekiq</span></span></code></pre></div></div>
<p>You can tell Overmind that certain processes are allowed to exit without bringing everything else
down, which is useful for one-time setup tasks like database migrations. Auto-restart on crash is
configurable per process, and port assignment is automatic and consistent so you can reference
one process&rsquo;s port from another&rsquo;s configuration.</p>
<p>The tmux integration turns out to be more than a dependency. If you&rsquo;ve ever wanted to connect to
a running Rails process to use the console, or poke at a webpack dev server that&rsquo;s behaving
strangely, <code>overmind connect</code> gets you there without restarting anything.</p>
<p><strong>Pros:</strong> Full PTY support, so colors work. Individual process control without affecting the
group. Auto-restart on crash. Port management per process. Highly configurable via environment
variables so your preferences apply globally across every project.</p>
<p><strong>Cons:</strong> Requires tmux. If tmux isn&rsquo;t already part of your workflow, this is a real dependency,
not just a binary download. Nested tmux sessions are manageable if you&rsquo;re already inside tmux,
but worth knowing about upfront.</p>
<hr>
<h3 id="overitall">Overitall</h3>
<p><a href="https://github.com/aha-app/overitall" target="_blank" rel="noopener noreffer ">Overitall</a> (<code>oit</code>) takes a different angle entirely.
Where the previous tools focus on running processes and multiplexing their output, Overitall
treats logs as a first-class concern. It&rsquo;s a TUI (terminal user interface) that combines process
management with what amounts to a built-in log viewer.</p>
<p>You still use a Procfile for defining processes, but a <code>.overitall.toml</code> config file can also
point at standalone log files rather than just process output. This is useful for services that
write to a file instead of stdout, or for situations where you want to correlate a running
process&rsquo;s output with a framework&rsquo;s own log file.</p>
<p>The TUI gives you real-time search (regex-capable), per-process visibility toggles, time-based
navigation (<code>:goto -5m</code>), and trace detection that can spot correlation IDs and UUIDs in your
logs and filter to a specific trace. For log-heavy development workflows, this is a meaningful
upgrade over watching interleaved output scroll by.</p>
<p>There&rsquo;s also an AI integration story: run <code>oit skill install</code> and Overitall installs a skill into
<code>.claude/skills/oit/</code> (or the Cursor equivalent) that teaches your AI assistant how to control
the running TUI from the command line. You can then ask Claude to check recent errors, restart a
process, or search the logs without leaving your editor.</p>
<p><strong>Pros:</strong> Log management as a first-class feature. Regex filtering, time navigation, and trace
detection. Interactive TUI with per-process visibility control. AI skill for Claude Code and
Cursor. Available on both macOS and Linux.</p>
<p><strong>Cons:</strong> Requires an additional <code>.overitall.toml</code> config file alongside the Procfile. The TUI
has a learning curve compared to a simple process runner. It&rsquo;s a newer project and still
maturing.</p>
<hr>
<h3 id="pitchfork">Pitchfork</h3>
<p><a href="https://pitchfork.jdx.dev" target="_blank" rel="noopener noreffer ">Pitchfork</a> is the most ambitious tool in this list, and also the
most different. Where everything else runs your processes in the foreground and stops them when
you close the terminal, Pitchfork manages daemons: background processes that keep running after
you walk away. A supervisor process handles them, and you interact with it via a CLI.</p>
<p>The configuration is a <code>pitchfork.toml</code> file:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-toml">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">daemons</span><span class="p">.</span><span class="nx">redis</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">run</span> <span class="p">=</span> <span class="s2">&#34;redis-server --port 6379&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">auto</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;start&#34;</span><span class="p">,</span> <span class="s2">&#34;stop&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">daemons</span><span class="p">.</span><span class="nx">api</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">run</span> <span class="p">=</span> <span class="s2">&#34;npm run dev&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">auto</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;start&#34;</span><span class="p">,</span> <span class="s2">&#34;stop&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">depends</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;redis&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">ready_http</span> <span class="p">=</span> <span class="s2">&#34;http://localhost:3000/health&#34;</span></span></span></code></pre></div></div>
<p>The <code>auto = [&quot;start&quot;, &quot;stop&quot;]</code> setting is the most interesting feature for the cross-project
consistency goal. With shell hooks activated, Pitchfork starts those daemons automatically when
you <code>cd</code> into the project directory and stops them when you leave. You never have to remember
to start your services. You just navigate to your project and they&rsquo;re there.</p>
<p>The feature list goes further: dependency ordering (so Redis starts before the API that needs
it), ready checks (so Pitchfork waits until a health endpoint actually responds before declaring
a service up), file watching for auto-restart, cron scheduling, resource limits, and a web UI.
Pitchfork also ships a built-in MCP server. One JSON config block in your Claude Desktop or
Cursor settings wires your AI assistant directly into daemon management.</p>
<p>Color output is available but requires opting in with <code>pty = true</code> per daemon. Since processes
run in the background by default, PTY is not on automatically, but enabling it is a one-line
change per daemon.</p>
<p><strong>Pros:</strong> The most powerful feature set in this review by a wide margin. Auto-start and auto-stop
via shell hooks directly address the cross-project consistency problem. Built-in MCP server for
AI integration. Dependency ordering, ready checks, and cron scheduling included. Language-agnostic
standalone binary.</p>
<p><strong>Cons:</strong> Steeper learning curve and a new configuration format to learn. The daemon model is a
different mental model than a foreground process runner. You check on services rather than
watching them scroll past. Color output requires explicit opt-in per daemon. Actively developed
and not yet at 1.0.</p>
<hr>
<h2 id="feature-comparison">Feature comparison</h2>
<table>
	<thead>
			<tr>
					<th>Feature</th>
					<th>Foreman</th>
					<th>Hivemind</th>
					<th>Overmind</th>
					<th>Overitall</th>
					<th>Pitchfork</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Config format</td>
					<td>Procfile</td>
					<td>Procfile</td>
					<td>Procfile</td>
					<td>Procfile + .overitall.toml</td>
					<td>pitchfork.toml</td>
			</tr>
			<tr>
					<td>Install</td>
					<td>Ruby gem</td>
					<td>Binary</td>
					<td>Binary</td>
					<td>Binary</td>
					<td>Binary</td>
			</tr>
			<tr>
					<td>Preserves colors (PTY)</td>
					<td>❌</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>Opt-in per daemon</td>
			</tr>
			<tr>
					<td>Interactive TUI</td>
					<td>❌</td>
					<td>❌</td>
					<td>via tmux</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>tmux integration</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>Connect to process</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>Restart individual process</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Process scaling (formation)</td>
					<td>✅</td>
					<td>❌</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>Auto-restart on crash</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Dependency ordering</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Daemon mode</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Shell auto-start hooks</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Ready checks</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Advanced log viewing</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>✅</td>
					<td>basic</td>
			</tr>
			<tr>
					<td>Export to init systems</td>
					<td>✅</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
					<td>❌</td>
			</tr>
			<tr>
					<td>macOS support</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>Linux support</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
					<td>✅</td>
			</tr>
			<tr>
					<td>AI integration</td>
					<td>&ndash;help / man page</td>
					<td>&ndash;help</td>
					<td>&ndash;help</td>
					<td>Claude/Cursor skill</td>
					<td>Built-in MCP server</td>
			</tr>
	</tbody>
</table>
<h2 id="which-tool-is-right-for-you">Which tool is right for you?</h2>
<p><strong>Choose Foreman</strong> if your project is already Ruby-based and you want the simplest possible
setup. The gem installs alongside your other dependencies, and the ecosystem familiarity is hard
to beat. It&rsquo;s also the right call if you need to export process definitions to init system
formats for production use, since no other tool here does that.</p>
<p><strong>Choose Hivemind</strong> if you want a no-frills Procfile runner that works for any
project in any language, with proper color output. It&rsquo;s an easy, zero-cost,
drop-in replacement for Foreman for those that don&rsquo;t need individual process
control.</p>
<p><strong>Choose Overmind</strong> if you live in tmux and want the ability to connect to, inspect, and restart
individual processes without tearing down your whole stack. Once you&rsquo;ve used <code>overmind connect</code>
to drop into a running process&rsquo;s window, it&rsquo;s probably hard to let that go.</p>
<p><strong>Choose Overitall</strong> if log management is a first-class concern in your
workflow. Noisy logs from multiple services, distributed traces to chase,
correlation IDs to filter on: this is where <code>oit</code>&rsquo;s search, filtering, and time
navigation start to pay off. I can see this being really useful in a more
complex $work environment. The AI skill integration is also a practical bonus
if you use Claude Code or Cursor heavily.</p>
<p><strong>Choose Pitchfork</strong> if you want services that follow you between projects and
start automatically without any deliberate action on your part. The daemon
model requires a mental shift, but it has a nice TUI (and Web UI) and it&rsquo;s the
most direct answer to the cross-project consistency problem in this roundup.</p>
<h2 id="the-verdict">The verdict</h2>
<p>If you&rsquo;re looking for a single tool to carry across every project regardless of language or
stack, the choice comes down to how much you want to invest in configuration upfront versus how
much friction you want to eliminate long-term.</p>
<p>For a lightweight universal runner, <strong>Hivemind</strong> is the pragmatic choice. Single binary, no
runtime dependencies, correct color output, zero learning curve. Drop a Procfile in a project and
you&rsquo;re running. The limitation of no individual process control rarely matters until it suddenly
does, and when it does, you know where to go next.</p>
<p>If you are already a tmux user, <strong>Overmind</strong> is the easy upgrade over Hivemind. Same binary
story, same Procfile format, but you gain individual process control and the ability to interact
directly with any running process. The additional capability costs almost nothing if tmux is
already in your toolkit.</p>
<p>The most interesting answer for the cross-project consistency problem, though,
is <strong>Pitchfork</strong>. Shell hooks that auto-start and auto-stop services as you
move between directories solve the problem at a deeper level than any
foreground runner can. You stop managing process startup entirely, and your
services are just there when you need them. That comes with more upfront
configuration and a different operational model, but if you work across many
projects and that investment appeals to you, Pitchfork is the tool that most
ambitiously reframes what a development process manager can be.</p>
<p>For myself, I&rsquo;m honestly not sure where I&rsquo;ll land quite yet. I have a soft spot
for the simplicity of Hivemind, I&rsquo;m not a frequent tmux user so Overmind&rsquo;s
advantages are less compelling, and Pitchfork&rsquo;s daemon model is intriguing but
requires extra work for me and my AI integration.</p>
]]></description></item><item><title>TestGenAI</title><link>https://tracyatteberry.com/portfolio/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><p>Building a presentation thingy.</p>
]]></description></item><item><title>TestGenAI: Building a Ruby CLI that writes your missing tests</title><link>https://tracyatteberry.com/posts/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="creating-a-gem-that-writes-your-missing-tests">Creating a gem that writes your missing tests</h1>
<p>No new project survives contact with the real world unscathed. We built
TestGenAI, ran it on itself, and it worked well. Then we ran it on another
codebase, and two things broke immediately. The fixes turned out to just as
interesting as the original build.</p>
<p>This is a walkthrough of how the tool works and what we learned when we took it
outside the greenhouse.</p>
<p>The code here is from <a href="https://github.com/grymoire7/testgenai" target="_blank" rel="noopener noreffer ">TestGenAI</a>, a
working Ruby CLI gem you can install and run against your own codebase.</p>
<h2 id="the-pipeline">The pipeline</h2>
<p>The pipeline has five stages:</p>
<ol>
<li>Scan your codebase to find classes and methods without test coverage</li>
<li>Build context for each untested method</li>
<li>Generate tests using an LLM with the mechanically curated context</li>
<li>Validate that the generated tests run and pass</li>
<li>Collect the results</li>
</ol>
<p>Each stage needs to be reliable enough that you can walk away and trust the
process to complete. That means handling errors gracefully, providing clear
output about what happened, and making it easy to pick up where things left off
if something breaks.</p>
<h2 id="finding-untested-code">Finding untested code</h2>
<p>Before you can generate tests, you need to know what needs testing. The right
approach depends on whether SimpleCov is available in the project.</p>
<p>If SimpleCov is set up, TestGenAI runs your test suite with <code>COVERAGE=true</code>,
reads the resulting <code>coverage/.resultset.json</code>, and uses AST parsing to find
methods where every executable line has zero hits. This scanner handles
partially-tested files correctly. It reports individual methods that were
never exercised, even if other methods in the same file have full coverage.</p>
<p>If SimpleCov isn&rsquo;t available, the scanner falls back to checking whether a spec
or test file exists for each source file. This approach is less accurate. A
file tested only through integration tests or through specs for its subclasses
will appear fully untested even if its methods are exercised constantly. The
SimpleCov scanner is worth setting up.</p>
<p>Both scanners share the same underlying logic for locating methods in source
files, which brings up something worth explaining.</p>
<h2 id="walking-the-ast">Walking the AST</h2>
<p>To locate methods, TestGenAI parses each Ruby source file into an abstract
syntax tree and walks it recursively. The walker looks for <code>:def</code> and <code>:defs</code>
nodes (instance and class methods), tracks the current class/module namespace,
and records each method&rsquo;s file, class, name, and line range.</p>
<p>That line range matters. The SimpleCov scanner uses it to check whether any
executable lines in the method had zero hits. A <code>nil</code> in SimpleCov&rsquo;s coverage
array means a line isn&rsquo;t executable, like a blank line, a comment, or an <code>end</code>.
The scanner filters those out before checking for zeros, so it only flags
methods where runnable code was never touched.</p>
<h3 id="the-parser-compatibility-problem">The parser compatibility problem</h3>
<p>To parse Ruby, the gem relies on the <code>parser</code> gem. In older versions, you&rsquo;d
call <code>Parser::CurrentRuby.parse(source)</code> and get back an AST. This worked fine
until Ruby 3.4, which switched its internal default parser to prism. Using
<code>Parser::CurrentRuby</code> with Ruby 3.4 produces warnings, and in some
configurations it fails entirely.</p>
<p>The prism project ships a compatibility shim,
<code>Prism::Translation::ParserCurrent</code>, that produces the same AST node types as
the old parser gem. The AST-walking code works unchanged. The only question is
which one to load.</p>
<p>The solution is a small file that runs at load time and sets a constant:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Testgenai</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="no">RUBY_VERSION</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="s2">&#34;3.4&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;prism&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Prism</span><span class="o">::</span><span class="no">Translation</span><span class="o">::</span><span class="no">ParserCurrent</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;parser/current&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Parser</span><span class="o">::</span><span class="no">CurrentRuby</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The rest of the codebase calls <code>CurrentParser.parse(source)</code> and never thinks
about which parser is underneath. The pattern of version check at load time,
constant as the abstraction is a clean way to handle the same kind of
compatibility gap you&rsquo;ll run into whenever Ruby ships a significant internal
change.</p>
<h2 id="context-generation-and-validation">Context, generation, and validation</h2>
<p>When you ask an LLM to write tests for a method, you can&rsquo;t just paste in the
method body. It needs the full class, the dependencies that file requires,
examples of how the method is called elsewhere in the codebase, and existing
test files it can match in style. Context quality is where quick-and-dirty AI
test generators fall apart, too little and the tests don&rsquo;t compile, too much
and you hit token limits.</p>
<p>The generator builds a prompt from all of that, sends it to the LLM via the
<code>ruby_llm</code> gem (which keeps the generator code provider-agnostic), and strips
any markdown fences from the response before passing it to the validator.</p>
<p>The validator writes the code to a temp file, runs <code>bundle exec rspec</code> or the
Minitest equivalent, and distinguishes between three outcomes: the file failed
to load (syntax errors, undefined constants), the tests ran but failed, or the
tests passed. Each outcome needs different handling. A file that doesn&rsquo;t load
gets deleted immediately because it&rsquo;s useless. A file that runs but fails gets
its error output fed back to the LLM for a retry.</p>
<p>The pipeline retries up to three times, passing failure details back each time.
LLMs are reasonably good at fixing specific errors when told what went wrong.
Undefined constants and wrong require paths almost always resolve in one retry.
More complex failures, like incorrect behavior assumptions, may not, and those
end up in a failed bucket for manual review.</p>
<h2 id="then-we-ran-it-on-a-real-project">Then we ran it on a real project</h2>
<p>The first external test run revealed two problems, both on the same day.</p>
<p>The first: generated tests were syntactically valid, ran, and passed — but they
looked nothing like the rest of the project&rsquo;s test suite. Wrong authentication
setup, wrong factory usage, helpers that weren&rsquo;t available. Tests that
technically pass but violate project conventions create a maintenance burden.</p>
<p>The second: the tool was silently destroying existing tests. When a spec file
already existed at the output path, the pipeline would overwrite it with the
newly generated content. Any tests already in that file were gone.</p>
<p>Both problems make complete sense in retrospect. The tool had only ever run on
its own codebase, where it was always generating new files and where the
conventions were deeply familiar to the model from the context it was seeing. A
different project broke both assumptions.</p>
<h2 id="fixing-the-conventions-gap">Fixing the conventions gap</h2>
<p>The core problem is that the LLM knows what your method does, but it doesn&rsquo;t
know how your team writes tests. It doesn&rsquo;t know that you authenticate in
<code>before</code> blocks a certain way, or that you have specific factory traits
available, or that you&rsquo;re not using <code>rails-controller-testing</code> so <code>assigns</code>
isn&rsquo;t an option.</p>
<p>The fix is a conventions system with two parts.</p>
<p><code>ConventionsExtractor</code> scans your existing test files and pulls out mechanical
facts: the most common authentication setup pattern, available factory traits
from your factories directory, frequently stubbed objects, whether
transactional fixtures are disabled and how cleanup is handled, and whether
specific helpers are unavailable based on what&rsquo;s in your Gemfile. These aren&rsquo;t
judgments, they&rsquo;re observations extracted directly from the code.</p>
<p><code>ConventionsSynthesizer</code> takes those raw facts and sends them to the LLM with a
prompt asking it to write a concise conventions guide explaining the rule
behind each pattern. The result is plain prose. Something like &ldquo;authentication
is handled in <code>before</code> blocks using <code>session[:user_id] =</code> rather than Devise
helpers; use this pattern consistently.&rdquo; That text gets prepended to every
generation prompt.</p>
<p>The synthesized guide is cached to <code>spec/conventions.md</code> and invalidated
automatically when spec files or Gemfiles change. Regenerating it costs one LLM
call.</p>
<p>Enable it with the <code>--conventions</code> flag:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7 --conventions</span></span></code></pre></div></div>
<p>Add <code>spec/conventions.md</code> to your <code>.gitignore</code>. It&rsquo;s a derived artifact and
probably not something to check in.</p>
<h2 id="fixing-the-overwrite-problem">Fixing the overwrite problem</h2>
<p>The overwrite bug was straightforward to diagnose and subtle to fix correctly.</p>
<p>The naive fix would be to skip generation if a spec file already exists. That&rsquo;s
less than ideal. The point is to add tests for untested methods, and
partially-tested files are the most common case.</p>
<p>Better behavior is to inject the generated tests into the existing file. The
pipeline now reads existing content before doing anything else. It combines
existing content with the newly generated code and validates the combined file.
If it passes, the combined content is written to the spec file.</p>
<p>If it fails, the pipeline restores the original file exactly as it was and
writes the generated-only code to a fallback path. A method in
<code>lib/payments/processor.rb</code> that already has a
<code>spec/payments/processor_spec.rb</code> would get its fallback at
<code>spec/payments/processor_context_spec.rb</code> (using the method name to scope the
filename). The failure output tells you where to find it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><pre tabindex="0"><code>  ✗ Payments::Processor#context failed after 3 attempt(s)
    → Generated tests saved to spec/payments/processor_context_spec.rb for manual review</code></pre></div>
<p>You end up with your original tests intact and the generated attempt sitting
somewhere you can look at it and decide what to do.</p>
<h2 id="running-it">Running it</h2>
<p>Install the gem and point it at your project:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gem install testgenai
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> your_project
</span></span><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7</span></span></code></pre></div></div>
<p>Or add it to your Gemfile in the development group and use <code>bundle exec</code>.
Configuration can also come from environment variables:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_PROVIDER</span><span class="o">=</span>anthropic
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_MODEL</span><span class="o">=</span>claude-opus-4-7
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>your_api_key
</span></span><span class="line"><span class="cl">testgenai generate --conventions</span></span></code></pre></div></div>
<p>Three diagnostic commands are available before you commit to a full run:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai scan      <span class="c1"># find untested methods without making any API calls</span>
</span></span><span class="line"><span class="cl">testgenai context   <span class="c1"># show what context would be sent to the LLM for each method</span></span></span></code></pre></div></div>
<p><code>scan</code> gives you a picture of your coverage gaps. <code>context</code> is useful for
understanding what the LLM will see before spending API credits.</p>
<p>The goal isn&rsquo;t to replace the developer who understands the code and makes
decisions about testing. It&rsquo;s to handle the mechanical work: setting up
describe blocks, wiring test data, writing happy-path coverage. Then you can
spend your time on the parts that actually need your judgment. The second
project taught us that &ldquo;mechanical&rdquo; is more context-dependent than it looks.</p>
]]></description></item><item><title>Building Jojo: turning job applications into marketing campaigns</title><link>https://tracyatteberry.com/posts/jojo/</link><pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/jojo/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/jojo/landing_page.png" referrerpolicy="no-referrer">
            </div><h1 id="building-jojo-turning-job-applications-into-marketing-campaigns">Building Jojo: turning job applications into marketing campaigns</h1>
<p>When you apply for a job, you&rsquo;re competing against hundreds of other candidates.
Most of them submit a resume and a cover letter. The ambitious ones tailor those
documents to the role. And then everyone waits.</p>
<p>No matter how good your resume is, it&rsquo;s still a PDF in a pile of PDFs. You&rsquo;re
asking a hiring manager to do the work of figuring out why you&rsquo;re a fit. What
if you did that work for them?</p>
<p>That&rsquo;s the idea behind <a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">Jojo</a>, a Ruby CLI I
built to transform job applications into personalized marketing campaigns.
Instead of sending documents, you send a package: a tailored resume, a cover
letter informed by company research, and a dedicated landing page that shows
exactly why you&rsquo;re a match for the role.</p>
<p>The landing page is the centerpiece. It&rsquo;s a mini marketing site with an
annotated job description that maps your experience to their requirements,
portfolio projects selected for relevance to their tech stack, a branding
statement written for their company, LinkedIn recommendations, an FAQ section,
and a call-to-action to schedule a conversation. It turns a passive application
into an active pitch.</p>
<p>Think of it as treating each job application like a product launch. You&rsquo;re the
product. The company you&rsquo;re applying to is the only customer. Jojo builds the
marketing campaign.</p>
<div class="mermaid" id="id-2"></div>
<h2 id="how-it-works">How it works</h2>
<p>The workflow starts with two inputs: your resume data (a structured YAML file)
and a job description (a file or URL). From there, Jojo runs a pipeline of
AI-powered generation steps.</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Create a new application workspace</span>
</span></span><span class="line"><span class="cl">jojo new --slug acme-senior-dev --job posting.txt
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Generate everything</span>
</span></span><span class="line"><span class="cl">jojo generate --slug acme-senior-dev</span></span></code></pre></div></div>
<p>The <code>generate</code> command kicks off a sequence:</p>
<ol>
<li><strong>Research</strong> — AI analyzes the job description and (optionally) searches the
web to build a research document about the company, the role, and how to
position yourself.</li>
<li><strong>Resume</strong> — Your structured resume data is curated and rendered into a
tailored resume, emphasizing the most relevant experience.</li>
<li><strong>Branding</strong> — AI writes a personal branding statement specific to the
company and role.</li>
<li><strong>Cover letter</strong> — Generated from the research and tailored resume, so it
references specific things about the company rather than generic platitudes.</li>
<li><strong>Annotations</strong> — The job description is analyzed requirement by requirement,
with each one mapped to your matching experience.</li>
<li><strong>FAQ</strong> — AI generates role-specific questions and answers based on your
background and the job requirements.</li>
<li><strong>Website</strong> — Everything comes together in a self-contained landing page.</li>
<li><strong>PDF</strong> — Resume and cover letter are converted to PDF via Pandoc.</li>
</ol>
<p>Each step feeds into the next. The research informs the resume tailoring. The
resume informs the cover letter. The annotations and FAQ feed into the website.
It&rsquo;s a pipeline, not a collection of independent scripts.</p>
<p>Every application gets its own workspace directory organized by slug:</p>
<pre>
  applications/acme-senior-dev/
  ├── job_description.md
  ├── job_details.yml
  ├── research.md
  ├── resume.md
  ├── cover_letter.md
  ├── branding.md
  ├── faq.json
  ├── job_description_annotations.json
  ├── status.log
  └── website/
      └── index.html
</pre>
<p>For daily use, there&rsquo;s also an interactive TUI mode. Running <code>jojo</code> with
no arguments launches a dashboard that shows all your applications, tracks which
steps are complete, detects when artifacts are stale (because you regenerated a
dependency), and lets you generate or regenerate individual steps with a
keypress. The staleness detection uses file modification times. If you
regenerate your research, the dashboard knows your resume is now stale because
it was built from the old research.</p>
<pre>
  ┌─ Jojo ────────────────────────────────────────────┐
  │  Active: acme-senior-dev                          │
  │  Company: Acme Corp  •  Role: Senior Developer    │
  ├───────────────────────────────────────────────────┤
  │  Workflow                           Status        │
  │  1. Job Description            $   ✓ Generated    │
  │  2. Research                   $   ✓ Generated    │
  │  3. Resume                     $   * Stale        │
  │  4. Cover Letter               $   ○ Ready        │
  │  ...                                              │
  ├───────────────────────────────────────────────────┤
  │  [1-9] Generate item    [a] All ready    [q] Quit │
  └───────────────────────────────────────────────────┘
</pre>
<p>The <code>$</code> indicator shows which steps call paid APIs, so you know if an action
will cost something before you press the key. Steps that just combine existing
artifacts (like website generation) are free.</p>
<h2 id="architecture-the-command-pipeline">Architecture: the command pipeline</h2>
<p>Jojo is over 5K lines of Ruby source across ~50 source files. Most CLI commands follow
the same three-file pattern:</p>
<pre>
  lib/jojo/commands/{command_name}/
  ├── command.rb    — Orchestration: validates inputs, manages file I/O
  ├── generator.rb  — Content generation: builds context, calls AI
  └── prompt.rb     — AI prompts: system and user prompt templates
</pre>
<p>So when I need to add a new command, I can create these three files, follow the
pattern from the existing commands, and it (hopefully/usually) works. I don&rsquo;t
have to modify a central router or understand the internals of unrelated
commands. The pattern helps make the codebase predictable. If you&rsquo;ve read one
command, you understand the shape of all of them. That helps the human and the
AI assistant.</p>
<p>This wasn&rsquo;t the original architecture. The CLI started as a monolith in
<code>cli.rb</code>. Thor command definitions were mixed with validation logic, file
handling, and generation orchestration. It worked fine for the first few
commands, but soon things got messy. Adding a new feature meant navigating a
growing code heap and hoping your changes didn&rsquo;t break something unrelated.</p>
<p>The refactor extracted each command into its own module with a shared base
class that provides common behavior (slug resolution, config loading, AI client
setup). The CLI file shrank to a thin router with about 150 lines of small
methods that delegate to command classes. Interactive mode, which breifly
had a circular dependency calling back into the CLI class (eww) now calls
command classes directly through a simple adapter.</p>
<h3 id="dual-ai-models">Dual AI models</h3>
<p>Jojo configures two AI models. There&rsquo;s a reasoning model for complex tasks and
a text generation model for simpler ones.</p>
<p>Company research and resume tailoring need the strongest reasoning capabilities
as they&rsquo;re analyzing job requirements, cross-referencing your experience, and
making judgment calls about relevance. But extracting metadata from a job
description (company name, location, job title) is easier. Using a powerful
model for that is like hiring a senior architect to hang shelves.</p>
<p>The reasoning model handles research, resume curation, and cover letter writing.
The text generation model handles job description processing, annotations, FAQ
generation, and branding statements. Both models are configurable per provider,
so you can use a frontier model for reasoning and a faster model for text
generation, or whatever suits your budget and quality needs.</p>
<p>Even with the right model architecture, the AI still has a fundamental
trustworthiness problem when it comes to factual content (welcome to AI).</p>
<h2 id="solving-the-hallucination-problem">Solving the hallucination problem</h2>
<p>This was a technical decision that came from a hard fail.</p>
<p>The original resume generation would take the user&rsquo;s resume data (stored as
structured YAML), combine it with the job description and research, and ask the
AI to generate a tailored resume in markdown. The prompt included extensive
instructions about not fabricating information. It said things like &ldquo;only
include skills the candidate actually has&rdquo; and &ldquo;do not add technologies not
present in the source data.&rdquo;</p>
<p>The AI ignored these instructions way too often. I&rsquo;d review a generated resume
and find &ldquo;Kubernetes&rdquo; listed in my skills because the AI noticed I mentioned
Docker and helpfully inferred I must know Kubernetes too. Or it would embellish
a job description with responsibilities I never had. For a resume, this is not
good.</p>
<p>The first instinct was to add more guardrails to the prompt. More emphatic
instructions. More examples of what not to do. This helped a little, but it
didn&rsquo;t solve the problem. The AI still had the <em>ability</em> to modify anything,
and language-level instructions are suggestions, not constraints.</p>
<h3 id="the-insight-different-fields-have-different-risk-profiles">The insight: different fields have different risk profiles</h3>
<p>A professional summary should be rewritten for each role. That&rsquo;s the whole
point, but a list of programming languages must not be modified. The years you
worked at a company are facts. Your name is your name.</p>
<p>The problem was that &ldquo;AI shouldn&rsquo;t have the same permissions everywhere.&rdquo; Some
fields need smart tailoring. Others need strict preservation. And still others
might be removed or reordered. The idea was to define a permission system
that specifies what the AI can for different kinds of content.</p>
<h3 id="permission-based-curation">Permission-based curation</h3>
<p>The solution was a permission system embedded directly in the resume data:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-yaml">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Bob Denver&#34;</span><span class="w">               </span><span class="c"># default: read-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">email</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;bob@example.com&#34;</span><span class="w">         </span><span class="c"># default: read-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">summary: |                       # permission</span><span class="p">:</span><span class="w"> </span><span class="l">rewrite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">Polyglot developer who enjoys solving problems</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">with software...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">skills:                          # permission</span><span class="p">:</span><span class="w"> </span><span class="l">remove, reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">software engineering</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">full stack development</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">AI assisted development</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">languages:                       # permission</span><span class="p">:</span><span class="w"> </span><span class="l">reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Ruby</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Java</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Python</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Go</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">experience:                      # permission</span><span class="p">:</span><span class="w"> </span><span class="l">reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">company</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Island Adventures Inc.&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Senior Software Engineer&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">start_date</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2020-07&#34;</span><span class="w">        </span><span class="c"># read-only (nested)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">description: |               # permission</span><span class="p">:</span><span class="w"> </span><span class="l">rewrite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="l">Full-stack developer delivering a SaaS platform...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">technologies:                # permission</span><span class="p">:</span><span class="w"> </span><span class="l">remove, reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Ruby on Rails</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Vue</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Python</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Docker</span></span></span></code></pre></div></div>
<p>Four permission levels:</p>
<ul>
<li><strong>read-only</strong> (default) — AI cannot modify, delete, add, or reorder. Contact
info, dates, company names.</li>
<li><strong>remove</strong> — AI can exclude irrelevant items but can&rsquo;t modify the ones it
keeps. A database skill list can drop SQLite if the role is all PostgreSQL.</li>
<li><strong>reorder</strong> — AI can prioritize by relevance but can&rsquo;t remove or modify. Your
programming languages list stays complete but puts the most relevant ones
first.</li>
<li><strong>rewrite</strong> — AI can generate new content using the original as a factual
baseline. Professional summary, job descriptions.</li>
</ul>
<p>In particular, the AI should never <em>add</em> items that aren&rsquo;t in the source data.
Though there is still a risk of hallucination in rewrite fields, the presence
of original content in smaller chunks provides a grounding that makes it less
likely.</p>
<h3 id="two-pass-pipeline">Two-pass pipeline</h3>
<p>The curation happens in two passes:</p>
<p><strong>Pass 1: Filter and reorder.</strong> The AI receives the full resume data and the
job description. It returns a filtered, reordered version that respects the
permissions on each field. Skills marked <code>remove, reorder</code> get filtered to ~70%
of the most relevant items and sorted by relevance. Lists marked <code>reorder</code> get
sorted but all items are preserved.</p>
<p><strong>Pass 2: Rewrite fields.</strong> The AI receives the filtered data and generates new
content for fields marked <code>rewrite</code>. For example, the professional summary and
experience descriptions. It uses the original content as a factual baseline.</p>
<p>Then an ERB template renders the final markdown. The template handles structure
and formatting. The AI never touches the output templating.</p>
<p>What makes this work as an engineering solution is that the Ruby code
<em>enforces</em> the permissions where possible. If the AI returns a reordered list
that&rsquo;s shorter than the original for a field that only has <code>reorder</code>
permission, the <code>Transformer</code> class raises a <code>PermissionViolation</code> error:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">unless</span> <span class="n">can_remove</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">indices</span><span class="o">.</span><span class="n">length</span> <span class="o">!=</span> <span class="n">original_count</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="no">PermissionViolation</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;LLM removed items from reorder-only field: </span><span class="si">#{</span><span class="n">field_path</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The permissions are no longer buried in  prompt instructions that the AI might
ignore. They&rsquo;re enforced in code. The AI provides <em>suggestions</em> for how to
curate the data, and the Ruby code validates those suggestions against the
permission rules before applying them. If the AI tries to exceed its
permissions, the operation fails rather than silently producing a resume with
fabricated content.</p>
<p>The result is a skills section always contains skills I actually have. My job
dates are always accurate. But my professional summary is tailored for each
role, emphasizing the experience most relevant to that specific position.</p>
<h3 id="what-structured-data-enables">What structured data enables</h3>
<p>In order to make the permission system work, we had to switch from an unstructured
markdown resume to a structured YAML format. This was a significant
architectural change and it required reworking the entire resume generation
pipeline. However, it was necessary to address the hallucination problem.</p>
<p>The permission system is the most visible benefit of using structured data, but
there are other advantages:</p>
<ul>
<li><strong>Narrower AI focus</strong> — With structured data, the AI can focus on curating
specific fields rather than trying to parse and understand a free-form markdown
document. This leads to better quality and more consistent results.</li>
<li><strong>Better output control</strong> — The ERB template handles formatting and
structure, so the AI only generates smaller pieces of content. This reduces
the chances of formatting errors or hallucinated sections and increases the
human control over the final output.</li>
<li><strong>Easier testing</strong> — Structured data is easier to work with in tests. You can
create synthetic resume data with specific permissions and verify that the
output respects those permissions. With unstructured markdown, it&rsquo;s harder to
assert that the AI didn&rsquo;t add or modify content it shouldn&rsquo;t have.</li>
</ul>
<h2 id="testing-as-a-development-discipline">Testing as a development discipline</h2>
<p>A permission system that enforces constraints in code is only trustworthy if
you actually test the enforcement. Jojo has 530 tests across two tiers, with
84% code coverage. Getting there was an intentional investment.</p>
<p>AI coding assistants are enthusiastic about writing features. They&rsquo;re less
enthusiastic about writing tests. This mirrors human tendencies. Tests aren&rsquo;t
as exciting as shipping the next feature, but with AI-assisted development the
gap is amplified.</p>
<p>When the first large refactor was needed I noticed that test coverage was
sitting at 31%. The code worked, but I had no safety net for refactoring. The
push to 84% was a conscious decision to invest in change enablement.</p>
<h3 id="three-kinds-of-tests">Three kinds of tests</h3>
<p>Jojo has three kinds of tests:</p>
<table>
	<thead>
			<tr>
					<th>Kind of test&hellip;</th>
					<th>It tests&hellip;</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Unit tests</td>
					<td>Do small units work?</td>
			</tr>
			<tr>
					<td>Integration tests</td>
					<td>Do small units work together?</td>
			</tr>
			<tr>
					<td>Linting</td>
					<td>Static code analysis</td>
			</tr>
	</tbody>
</table>
<p>All tests run on every <code>./bin/test</code> (or <code>rake test:all</code>) invocation and in CI.</p>
<h3 id="testing-api-dependent-code">Testing API-dependent code</h3>
<p>The trickiest part of testing Jojo is that a lot of interesting work
involves AI and Search API calls. You can&rsquo;t run those in CI without spending
money on every test run, but you also want tests that exercise real response
parsing.</p>
<p>The solution was the VCR gem. VCR records real HTTP interactions the first
time a test runs and saves them as &ldquo;cassettes.&rdquo; On subsequent runs, it replays
the recorded responses instead of making real API calls. You get fast,
deterministic tests that still exercise the full response-parsing pipeline.</p>
<h3 id="fixture-discipline">Fixture discipline</h3>
<p>One rule that has saved me more than once is that tests (and AI) never touch
the <code>inputs/</code> directory — no matter how much AI would like to. That directory
contains real resume data from the user. Tests use <code>test/fixtures/</code>
exclusively, with synthetic data designed for testability.</p>
<p>This is codified in the project&rsquo;s AI guidelines, which was previously prone to
such mistakes. The instructions are explicit, emphatic, and took a few
iterations to be effective. This testing discipline was part of the broader
experience of building with AI.</p>
<h2 id="building-with-ai">Building with AI</h2>
<p>There&rsquo;s a meta quality to this project: it&rsquo;s a tool that uses AI to generate
content, and it was built with AI assistance. Both
<a href="https://claude.ai/code" target="_blank" rel="noopener noreffer ">Claude</a> and <a href="https://z.ai" target="_blank" rel="noopener noreffer ">Z</a> helped with
development.</p>
<p>AI is pretty good at generating boilerplate, brainstorming design alternatives,
and automating the tedious parts of refactoring (like updating 50 files when
you rename a class).</p>
<p>But the decisions this post is about — the curation system, the architecture,
the decision to refactor and when, the test organization — those were human
decisions (as was the choice to use em-dashes just then). AI helped implement
them a bit faster, but it didn&rsquo;t tell me they were needed.</p>
<p>One nice thing about AI-assisted development was the ability to explore
approaches quickly. When I was designing the permission system, I could
describe different architectures, brainstorm, and get working prototypes, all
in fairly short order. That kind of rapid experimentation is really helpful.
The design thinking, however, still has to be yours.</p>
<p>One not-so-nice thing was needing to prod the AI to write tests for the
features it&rsquo;s helping to build. Also, let&rsquo;s be honest, there&rsquo;s a temptation to
let the AI go a little too long before reviewing its output. Left to its own
devices, an AI assistant will happily build feature after feature, with no test
coverage and growing technical debt. Just like a human developer on a deadline,
it needs someone to say &ldquo;we&rsquo;re not adding anything else until we address the
technical debt, and that includes tests.&rdquo;</p>
<h2 id="what-i-learned-and-whats-next">What I learned and what&rsquo;s next</h2>
<p>A few things I&rsquo;d do differently if I started over:</p>
<p><strong>Start with structured data sooner.</strong> The original design used a free-form
markdown resume as input. This was a frightful battle of prompt engineering
from the beginning. The switch to structured YAML data (<code>resume_data.yml</code>) was
the right call, but it required reworking the entire resume generation
pipeline. If I&rsquo;d started with structured data, the permission system would have
been a natural extension rather than a redesign.</p>
<p><strong>Build the interactive mode earlier.</strong> The TUI dashboard made the tool
dramatically more usable, but it came in Phase 6 out of 7. Earlier access to
the dependency graph and staleness detection would have improved my own
workflow during development.</p>
<p><strong>Force TDD from the start, or very near it.</strong> I had a test suite from the
beginning, of course, but it wasn&rsquo;t until I hit a major refactor that I made a
conscious decision to invest in better test coverage. If I had enforced TDD
from the start.</p>
<p>Basically, I would have spent a lot more time up front on planning the
architecture and testing strategy, which would have made the development
process smoother and more maintainable. AI assistance can be great, but
it&rsquo;s also really good at seducing you into bad habits.</p>
<h3 id="whats-next">What&rsquo;s next</h3>
<p>A few potential things for the roadmap:</p>
<ul>
<li><strong>Interview prep generation</strong> — STAR-method examples drawn from your resume
data, tailored to the specific role</li>
<li><strong>More and better theming options</strong> — The landing page is Jojo&rsquo;s UVP, but the
current design is pretty basic. More themes and customization options would let
users create a landing page that better reflects their personal brand.</li>
<li><strong>Application tracking</strong> — Status tracking across all applications with dates,
notes, and follow-up reminders</li>
<li><strong>Full SaaS product</strong> — A Rails app version of Jojo with a user-friendly interface
and full job search management features — this would be a much bigger project
but could help a wider audience.</li>
</ul>
<h3 id="try-it-out">Try it out</h3>
<p>Jojo is open source and available on
<a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">GitHub</a> with a <a href="https://grymoire7.github.io/jojo/" target="_blank" rel="noopener noreffer ">documentation
site</a>. It&rsquo;s a Ruby CLI that requires AI and
Search provider API keys. Setup takes just a few minutes.</p>
<p>If you&rsquo;re interested in the code, the architecture, or just want to talk about
AI-assisted development, I&rsquo;d enjoy hearing from you. You can find me on
<a href="https://linkedin.com/in/tracyatteberry" target="_blank" rel="noopener noreffer ">LinkedIn</a> or <a href="https://mastodon.social/@grymoire7" target="_blank" rel="noopener noreffer ">Mastodon</a>.</p>
]]></description></item><item><title>Building a Ruby CLI gem for Hyrum's Law</title><link>https://tracyatteberry.com/posts/hyrum/</link><pubDate>Thu, 20 Nov 2025 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/hyrum/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/hyrum/hyrum_top.png" referrerpolicy="no-referrer">
            </div><h1 id="building-a-ruby-cli-gem-for-hyrums-law">Building a Ruby CLI gem for Hyrum&rsquo;s Law</h1>
<p>When you build a public API, users will depend on behaviors you never intended
to guarantee. It&rsquo;s called Hyrum&rsquo;s Law, and it&rsquo;s particularly tricky when it
comes to error messages. Change &ldquo;User not found&rdquo; to &ldquo;No such user exists&rdquo; and
someone&rsquo;s regex breaks in production at 2am.</p>
<p>I built Hyrum to solve for  this. It&rsquo;s a Ruby CLI gem that uses AI to generate
variations of status messages, ensuring users never become dependent on exact
wording. It evolved from a single-provider tool into a multi-provider platform
that cut costs by 10x, reduced code complexity from 12 to 1, and added quality
validation for AI-generated content.</p>
<h2 id="the-problem-with-predictable-messages">The problem with predictable messages</h2>
<p><a href="https://www.laws-of-software.com/laws/hyrum/" target="_blank" rel="noopener noreffer ">Hyrum&rsquo;s Law</a> states that
all observable behaviors of your system will be depended on by somebody.
This creates a dilemma for API designers: you want clear, consistent
error messages, but you don&rsquo;t want users parsing them as if they were
structured data.</p>
<p>Error codes help with this. Return <code>404</code> or <code>E_NOT_FOUND</code> and the
message text can evolve independently. But this only works if you&rsquo;re
disciplined about using codes for everything that matters. In practice,
some context lives in the message text, and someone will parse it.</p>
<p>The traditional solution is thorough documentation warning against this.
The pragmatic solution is accepting that some percentage of users will
do it anyway. The solution presented here is to make the messages unpredictable
by design.</p>
<h2 id="building-the-initial-solution">Building the initial solution</h2>
<p>The first version was straightforward. The gem takes a message like &ldquo;The
server refuses the attempt to brew coffee with a teapot&rdquo; and generates
code in your language of choice (Ruby, JavaScript, Python, Java, or
JSON) that returns variations at random:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Messages</span>
</span></span><span class="line"><span class="cl">  <span class="no">MESSAGES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="ss">e418</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Invalid Brewing Method&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Teapot not designed for coffee brewing&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Please use a suitable brewing device&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">message</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">MESSAGES</span><span class="o">[</span><span class="n">key</span><span class="o">].</span><span class="n">sample</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>I started with OpenAI&rsquo;s API via the <code>ruby-openai</code> gem. It worked well
enough for the core use case. But eventually, two problems emerged.</p>
<p>First, cost. Running <code>gpt-4</code> for simple message generation was using a
chainsaw to cut butter. Second, vendor lock-in. Some projects had
Anthropic credits, others used local Ollama models. I needed to support
multiple providers without maintaining provider-specific code paths.</p>
<h2 id="the-migration-decision">The migration decision</h2>
<p>There were three options:</p>
<ol>
<li>Build custom adapters for each provider</li>
<li>Find an abstraction layer that handled the differences</li>
<li>Accept the limitation and move on</li>
</ol>
<p>Building custom adapters would provide complete control, but at the cost
of maintaining provider-specific logic as APIs evolved. Option three was
tempting but unsatisfying.</p>
<p>I chose option two, migrating to
<a href="https://github.com/crmne/ruby_llm" target="_blank" rel="noopener noreffer ">ruby_llm</a>. More than
swapping dependencies, this was a fundamental architecture change that
would affect testing strategy, error handling, configuration, and the
public API.</p>
<h2 id="key-technical-decisions">Key technical decisions</h2>
<h3 id="cost-optimization-through-model-selection">Cost optimization through model selection</h3>
<p>The most impactful decision was switching from premium to budget models.
For Anthropic, this meant <code>claude-sonnet-4</code> to <code>claude-haiku-20250514</code>,
a roughly <em><strong>10x cost reduction</strong></em>.</p>
<p>This wasn&rsquo;t about being cheap. (Okay, maybe it was a little bit about that.) It
was (mostly) about matching model capability to task complexity. Generating
three variations of &ldquo;Resource not found&rdquo; doesn&rsquo;t require deep reasoning. Budget
models handle it perfectly well. The quality remained identical while costs
dropped by an order of magnitude.</p>
<h3 id="testing-strategy-mock-at-the-right-level">Testing strategy: mock at the right level</h3>
<p>The original implementation used VCR to record HTTP interactions. This
is a common pattern, but it had problems:</p>
<ul>
<li>Maintaining cassettes for 10+ providers would be tedious</li>
<li>Tests would break when ruby_llm changed request formats</li>
<li>We would be testing ruby_llm&rsquo;s HTTP implementation, not our code</li>
</ul>
<p>The better approach: mock at the ruby_llm interface level. Instead of
recording HTTP traffic, we mock <code>RubyLLM.chat()</code> directly. One mock
setup works for all providers. Tests are faster, more maintainable, and
focused on our actual logic.</p>
<p>This eliminated the need for VCR and WebMock entirely, <em><strong>removing two
dependencies.</strong></em> 🎊</p>
<p>As an additional safeguard, I set up a GitHub Actions workflow that runs
the full test suite on every push. This catches regressions before they
reach main and provides confidence when accepting contributions. It&rsquo;s a
small addition that pays dividends in long-term maintainability.</p>
<h3 id="code-simplification-through-extraction">Code simplification through extraction</h3>
<p><code>FakeGenerator</code> started at 298 lines with embedded message data.
Extracting the messages to an external JSON file and refactoring the
logic brought it down to 36 lines. That&rsquo;s an 88% reduction.</p>
<p>Another big win was in <code>AiGenerator</code>. By letting ruby_llm handle provider
differences, the <em><strong>cyclomatic complexity dropped from 12 to 1</strong></em>. Twelve
decision points (checking provider types, handling edge cases) collapsed into a
single code path.</p>
<p>This is the value of a good abstraction layer. It reduces the lines of code,
sure, but it also reduces the number of things you have to think about.</p>
<p>To be honest, this is also currently a pain point. We swapped ruby-openai&rsquo;s
abastraction for a better one, but in an evolving ecosystem, we may need to
swap again. The key is that the architecture is now flexible enough to
accommodate future changes more easily.</p>
<h3 id="breaking-changes-as-a-design-tool">Breaking changes as a design tool</h3>
<p>The migration required environment variable changes:</p>
<ul>
<li><code>OPENAI_ACCESS_TOKEN</code> → <code>OPENAI_API_KEY</code></li>
<li><code>OLLAMA_URL</code> → <code>OLLAMA_API_BASE</code></li>
</ul>
<p>I considered adding migration helpers to detect old variables and warn
users. But the gem was pre-1.0 with minimal adoption. Adding complexity
for hypothetical users would hurt future maintainability more than it
helped current users.</p>
<p>The cleaner approach: document the breaking changes clearly, provide a
migration guide, and move forward with consistent naming. Sometimes the
right trade-off is accepting short-term pain for long-term simplicity.</p>
<h2 id="implementation-approach">Implementation approach</h2>
<p>I followed a disciplined TDD approach for the migration:</p>
<ol>
<li>Write failing tests for <code>AiGenerator</code></li>
<li>Implement minimal code to pass</li>
<li>Add error handling tests</li>
<li>Implement error handling</li>
<li>Verify across multiple providers</li>
</ol>
<p>Each commit represented a logical unit of work with a clear purpose. The
git history tells a story: dependency updates, test infrastructure, new
generator implementation, factory updates, cleanup, documentation.</p>
<p>This matters for maintainability. Six months from now, when I need to
add a new provider or debug an edge case, the git history explains not
only what changed but why.</p>
<h2 id="validating-non-deterministic-output">Validating non-deterministic output</h2>
<p>Getting AI to generate message variations is great, but
how do you know if the variations are any good?</p>
<p>Building a system that validates the quality of non-deterministic output
requires deeper thinking about what &ldquo;quality&rdquo; even means in this context.</p>
<h3 id="defining-useful-variation">Defining useful variation</h3>
<p>A good variation needs two properties:</p>
<ol>
<li><strong>Semantic similarity</strong> - It preserves the original message&rsquo;s meaning</li>
<li><strong>Lexical diversity</strong> - It uses different wording than other variations</li>
</ol>
<p>These goals exist in tension. Perfect similarity means identical text.
Perfect diversity means unrelated messages. The sweet spot is variations
that mean the same thing but say it differently.</p>
<p>I created a validation system that measures both metrics
and combines them into an overall quality score. This lets you validate
generated variations automatically.</p>
<h3 id="the-initial-design-mistake">The initial design mistake</h3>
<p>My first implementation compared variations to each other. Generate
five variations, measure how similar they are as a group, done. This
seemed logical until I tested it.</p>
<p>The problem: variations could be highly similar to each other but
completely different from the original message. A set of variations
about network timeouts would score well even if the original message
was about authentication failures. They were similar to each other,
but wrong.</p>
<p>The fix was obvious in hindsight: compare each variation to the
original message, not to other variations. Semantic similarity measures
how well each variation preserves the user&rsquo;s intent. Lexical diversity
measures how much the variations differ from each other. Two separate
concerns, two separate comparisons.</p>
<h3 id="semantic-similarity-with-embeddings">Semantic similarity with embeddings</h3>
<p>Measuring semantic similarity requires understanding meaning, not just
matching words. &ldquo;Server error&rdquo; and &ldquo;Internal server failure&rdquo; share
minimal text but convey the same concept. Simple string comparison
would fail.</p>
<p>The solution: embedding models. These convert text into high-dimensional
vectors where semantically similar content clusters together. Calculate
the cosine similarity between the original message&rsquo;s embedding and each
variation&rsquo;s embedding, and you have a numeric score for how well meaning
is preserved.</p>
<p>I designed this to be provider-agnostic from the start, learning from
the earlier migration experience. The validator uses <code>RubyLLM.embed()</code>
which works with any provider that supports embeddings (OpenAI, Google,
etc.). When embeddings aren&rsquo;t available, it falls back to a simpler
word overlap heuristic.</p>
<p>This graceful degradation was important. Users without embedding access
still get validation, just with reduced accuracy. The feature doesn&rsquo;t
silently fail or block users.</p>
<h3 id="api-design-for-optional-features">API design for optional features</h3>
<p>Quality validation needed to be opt-in. The core workflow is &ldquo;generate
variations and use them.&rdquo; Adding validation steps would slow things down
and require configuration. It needed to enhance the workflow without
disrupting it.</p>
<p>The CLI design reflects this:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Basic usage - no validation</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opt into validation</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby --validate
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Use in CI/CD with strict mode</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby --validate --strict --min-quality <span class="m">75</span></span></span></code></pre></div></div>
<p>Validation is off by default. Enable it when you want quality metrics.
Use <code>--strict</code> to fail builds when quality is too low. Use <code>--show-scores</code>
to include metrics in generated output.</p>
<p>Each flag serves a specific use case without cluttering the happy path.
This is backward compatible and makes the feature discoverable through
<code>--help</code> without overwhelming new users.</p>
<h2 id="what-i-learned">What I learned</h2>
<h3 id="shipping-a-ruby-gem-is-more-accessible-than-i-expected">Shipping a Ruby gem is more accessible than I expected</h3>
<p>I hadn&rsquo;t published a gem before this project. The Ruby ecosystem makes
it surprisingly straightforward: follow conventions for directory
structure, add a gemspec, and <code>gem build</code> handles the rest. RuboCop
enforces community standards, and RSpec provides solid testing patterns.</p>
<p>Most of the learning curve wasn&rsquo;t in the tooling. It was in the design
decisions around versioning, breaking changes, and API stability. Understanding
when to bump major vs minor versions, when breaking changes are acceptable, and
how much backward compatibility to maintain. These are judgment calls that come
with experience, not documentation per se.</p>
<h3 id="abstractions-have-a-cost-and-a-benefit">Abstractions have a cost and a benefit</h3>
<p>Ruby_llm&rsquo;s abstraction eliminated provider-specific code paths. But it
also added a dependency and gave up some provider-specific features
(like Anthropic&rsquo;s prompt caching). The trade-off made sense because the
gem&rsquo;s core use case doesn&rsquo;t need advanced features. Your mileage will
vary.</p>
<h3 id="model-selection-is-a-design-decision">Model selection is a design decision</h3>
<p>Defaulting to budget models wasn&rsquo;t about minimizing costs. It was about
right-sizing capability to task complexity. When your task genuinely
needs advanced reasoning, use advanced models. When it doesn&rsquo;t, you&rsquo;re
paying for capability you&rsquo;re not using.</p>
<h3 id="testing-at-the-right-abstraction-level-matters">Testing at the right abstraction level matters</h3>
<p>Mocking at the HTTP level tests the wrong thing. Mocking at the library
interface level tests your code. The latter is almost always better
unless you&rsquo;re specifically testing HTTP behavior.</p>
<h3 id="breaking-changes-are-acceptable-in-context">Breaking changes are acceptable in context</h3>
<p>Pre-1.0 software with limited adoption is the right time to make
breaking changes. Adding backward compatibility for a handful of users
creates technical debt that affects every future user. Sometimes the
generous thing is to break things cleanly.</p>
<h3 id="user-feedback-catches-design-flaws-early">User feedback catches design flaws early</h3>
<p>My initial quality validation design seemed sound in theory. It measured
variation quality as a group property. But the first test revealed the
flaw: variations could be similar to each other while being completely
unrelated to the original message.</p>
<p>This is why you test with real examples before building the whole system.
The fix (comparing to the original message) was trivial to implement
early. It would have been painful to retrofit later after building an
entire validation pipeline on the wrong assumption.</p>
<p>The lesson: design mistakes are inevitable. What matters is catching them
before they become weight-bearing walls in your architecture.</p>
<h3 id="graceful-degradation-beats-hard-dependencies">Graceful degradation beats hard dependencies</h3>
<p>Embedding models provide superior semantic similarity measurement. But
requiring them would block users whose AI providers don&rsquo;t support
embeddings. The word overlap fallback isn&rsquo;t as accurate, but it&rsquo;s
better than nothing.</p>
<p>This pattern appears throughout the gem. Can&rsquo;t access embeddings? Use
heuristics. Provider doesn&rsquo;t support structured output? Parse text.
Each graceful degradation expands the set of valid configurations.</p>
<p>The alternative is failing fast with clear errors. Both approaches are
valid, but for a tool that works across many providers, degradation
creates a better experience than strict requirements.</p>
<h3 id="validation-changes-what-done-means">Validation changes what &ldquo;done&rdquo; means</h3>
<p>Before quality validation, &ldquo;done&rdquo; meant &ldquo;generates variations.&rdquo; After,
it meant &ldquo;generates variations that preserve meaning while varying
wording.&rdquo; This shift changed the entire value proposition.</p>
<p>The interesting part is that validation makes the AI output more
trustworthy without requiring a better AI model. Same model, same cost,
but now you have quantitative confidence in the results. That&rsquo;s the
leverage of good metrics.</p>
<h2 id="the-result">The result</h2>
<p>Hyrum now supports 11 AI providers (OpenAI, Anthropic, Gemini, Ollama,
Mistral, DeepSeek, Perplexity, OpenRouter, Vertex AI, AWS Bedrock,
GPUStack) through a unified interface. The codebase is simpler,
tests are faster, and costs are 10x lower.</p>
<p>Quality validation adds confidence without complexity. Generate variations,
validate they preserve meaning while varying wording, and integrate the
results into your codebase with quantitative quality metrics. The
validation system works across all providers and degrades gracefully
when embeddings aren&rsquo;t available.</p>
<p>Best of all, the architecture can accommodate new providers and capabilities
without increasing complexity. When ruby_llm adds support for a new provider,
Hyrum gets it for free. When embedding models improve, quality validation
automatically benefits. That&rsquo;s the payoff of choosing the right abstractions.</p>
<p>The project is <a href="https://github.com/grymoire7/hyrum" target="_blank" rel="noopener noreffer ">open source on GitHub</a>.
If you&rsquo;re dealing with Hyrum&rsquo;s Law in your own APIs, or you&rsquo;re just
curious about the implementation details, check it out.</p>
]]></description></item></channel></rss>