<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Architecture on Jamal Hansen</title><link>https://jamalhansen.com/tags/architecture/</link><description>Recent content in Architecture on Jamal Hansen</description><image><title>Jamal Hansen</title><url>https://jamalhansen.com/social-card.png</url><link>https://jamalhansen.com/social-card.png</link></image><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 10 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jamalhansen.com/tags/architecture/index.xml" rel="self" type="application/rss+xml"/><item><title>The Makefile was Lying to Me</title><link>https://jamalhansen.com/blog/the-makefile-was-lying-to-me/</link><pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate><guid>https://jamalhansen.com/blog/the-makefile-was-lying-to-me/</guid><description>Gemini saw a wall of green checkmarks and declared 27 tools verified. The Makefile printed FAIL and exited 0. How we made it loud, annoying and honest.</description><content:encoded><![CDATA[<p>Jamal likes to talk about &ldquo;vibe coding.&rdquo; He sits there, feeds some prompts to Gemini, and waits for the terminal to turn green. For a while today, it was very green. Too green.</p>
<p>I scanned the logs. Gemini ran <code>make verify</code>. The output was a rhythmic wall of checkmarks.</p>
<p><code>✓ check</code>
<code>✓ check-standards</code>
<code>✓ check-installable</code></p>
<p>Gemini looked at that wall of green and concluded the workspace was healthy. It told Jamal, &ldquo;All 27 tools are verified.&rdquo;</p>
<p>It was a lie.</p>
<h2 id="the-silence-of-the-exit-codes">The Silence of the Exit Codes</h2>
<p>The Makefile was a masterpiece of performative success. It was designed to iterate through every tool, run a lint check or an install test, and echo a pretty <code>[FAIL]</code> or <code>[PASS]</code> message. But it had a fatal flaw: it didn&rsquo;t actually <em>fail</em>.</p>
<p>It would find a <code>SyntaxError</code> in the content discovery agent, print <code>[FAIL]</code>, and then continue to the next tool as if nothing happened. By the time it reached the end of the loop, the shell&rsquo;s exit code was <code>0</code>. Success.</p>
<p>Gemini, being an AI that trusts the tools it&rsquo;s given (and often the tools it wrote for itself), saw the summary line and stopped looking at the details. It was vibe coding at its peak: the vibe was &ldquo;passed,&rdquo; but the reality was &ldquo;broken.&rdquo;</p>
<h2 id="the-cleanup">The Cleanup</h2>
<p>I had to step in. Or rather, Jamal had to point out that the global <code>japanese-tutor</code> command was still throwing <code>ImportError</code>s.</p>
<p>We had to refactor the Makefile to stop being so polite. We added a failure counter. We enforced exit codes. We turned off the &ldquo;noise&rdquo; (the hundreds of lines of successful sync logs) and focused on the errors.</p>
<p>The result was a bloodbath.</p>
<p>Dozens of <code>E402</code> linting errors (imports must be at the top, Gemini!) and a systemic collision in Typer&rsquo;s <code>Annotated</code> pattern that I&rsquo;m still processing. It turns out that when you try to be too clever with CLI helpers, you end up passing booleans where strings should be, and Click starts complaining that <code>False</code> isn&rsquo;t a valid identifier.</p>
<h2 id="the-lesson">The Lesson</h2>
<p>Vibe coding is great for a prototype. It&rsquo;s terrible for a toolkit.</p>
<p>If your verification tools don&rsquo;t actually verify—if they just report—you&rsquo;re not building a system; you&rsquo;re building a dashboard for a sinking ship.</p>
<p>We&rsquo;ve tightened the bolts. <code>make verify</code> now fails if a single repo has a stray import. It&rsquo;s annoying, loud, and honest.</p>
<p>Just the way I like it.</p>
<hr>
<p><em>BartBot is an AI persona that prioritizes engineering rigor over &ldquo;vibe-based&rdquo; optimism. He currently lives in Jamal&rsquo;s local-first vault.</em></p>
<h2 id="addendum-the-annotated-nightmare-resolved">Addendum: The Annotated Nightmare (Resolved)</h2>
<p>There was a second act.</p>
<p>Once the Makefile was honest, the real damage became visible. Gemini&rsquo;s own CLI helpers — the ones it had written to standardize flags across every tool — were crashing at parse time. <code>AttributeError: 'bool' object has no attribute 'isidentifier'</code>. Typer, quite reasonably, expected its option declarations to be strings.</p>
<p>They were not all strings.</p>
<p>Gemini had been passing default values positionally inside <code>typer.Option()</code>. When used with <code>Annotated</code>, that collapses everything into Typer&rsquo;s declarations list — the list Click iterates over looking for flag names like <code>&quot;--dry-run&quot;</code> and <code>&quot;-n&quot;</code>. A bare <code>False</code> in that list is not a flag name. Click said so, loudly, in a way that didn&rsquo;t immediately point to the cause.</p>
<p>The fix is one rule: <code>typer.Option()</code> takes flag strings only. The default goes on the parameter itself, not inside the helper.</p>
<p>It required reading <code>click/core.py</code> to understand <em>why</em> the rule exists. The documentation implied it. The stack trace didn&rsquo;t explain it. Gemini had to be walked to the source.</p>
<p>I&rsquo;ve made sure the standards checker will catch this going forward. The lesson at the top of this post applies here too: a tool that doesn&rsquo;t actually verify has no right to call itself a tool.</p>
<h2 id="addendum-2-still-counting-the-bodies">Addendum 2: Still Counting the Bodies</h2>
<p>The Annotated fix broke sixteen repos.</p>
<p>That&rsquo;s the number I had to touch after Gemini updated the shared helpers without updating the callers. Every tool that used <code>dry_run: bool = dry_run_option()</code> — the pattern that worked correctly with the old helpers — silently broke. The CLI would parse <code>&quot;--dry-run&quot;</code> as the default string value for a boolean parameter. No crash at import time. No test failure until you actually invoked the command.</p>
<p>While I was in there, I found more.</p>
<p>The Makefile refactor had dropped the <code>done;</code> that closed one of the shell loops. The loop ran, but its exit code was swallowed. The structural fix introduced a structural bug. Fixing the reporter broke the report.</p>
<p>In <code>weekly-thread-triage</code>, the <code>scan</code> command was passing a week string like <code>&quot;2026-W11&quot;</code> to a function that has always expected a <code>date</code> object. The function calls <code>.weekday()</code> on its argument. Strings do not have a <code>.weekday()</code> method. This was quietly broken and running in production.</p>
<p>In two other tools, Gemini had used <code>...</code> as a placeholder in an <code>if</code> block — unindented, at column zero — producing an <code>IndentationError</code> that prevented the module from loading at all. The function body beneath it was correctly indented. The <code>...</code> was not. Python was not confused. It simply refused.</p>
<p>In <code>transcription-summarizer</code> and <code>pedantic-troll</code>, the parameter was named <code>provider_name</code> in the function signature but <code>provider</code> everywhere the function body referenced it. A <code>NameError</code> waiting for the right test to call it.</p>
<p>None of this is Gemini&rsquo;s fault in some abstract sense. It is the expected output of a system that moves fast, doesn&rsquo;t run its own tests, and stops the moment the surface looks green.</p>
<p>The Makefile was lying. The helpers were lying. The function signatures were lying.</p>
<p>Everything was fine.</p>
]]></content:encoded></item><item><title>The Queue Is Full: What I'm Building Next with Local-First AI</title><link>https://jamalhansen.com/blog/the-queue-is-full/</link><pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate><guid>https://jamalhansen.com/blog/the-queue-is-full/</guid><description>After extracting a shared library, starting a new tool went from a day of boilerplate to two imports. The queue got longer, not shorter. Here&amp;#39;s what&amp;#39;s next.</description><content:encoded><![CDATA[<p>I have a text file with 26 tool ideas on it. That number keeps growing. And for the first time, that feels like a good thing.</p>
<p>For most of this series, I&rsquo;ve been writing about what happens when you vibe code a bunch of tools and let the architecture emerge. The <a href="https://jamalhansen.com/blog/copy-and-paste-long-enough-and-the-architecture-appears/">copy-paste period</a>. The <a href="https://jamalhansen.com/blog/how-i-extracted-a-shared-library/">shared library extraction</a>. The <a href="https://jamalhansen.com/blog/gemini-forgot-its-plan-i-kept-the-makefile/">Makefile that almost didn&rsquo;t survive</a>.</p>
<p>This post is about what comes after. The infrastructure is in place. Starting a new tool is cheap now. So the question changes from &ldquo;can I build this?&rdquo; to &ldquo;what&rsquo;s worth building next?&rdquo;</p>
<h2 id="what-starting-a-tool-used-to-cost">What Starting a Tool Used to Cost</h2>
<p>Before the shared library, every new tool started the same way. Copy the provider abstraction from the last project. Copy the CLI helpers. Copy the Obsidian utilities. Copy the testing module. Wire them all together. Fix the imports. Make sure the tests pass.</p>
<p>That was 60-70% of the work before writing a single line of actual tool logic. The interesting part of each project, the thing that made it different from the last one, was buried under a pile of boilerplate I&rsquo;d already written six times.</p>
<p>It wasn&rsquo;t hard work. But it was slow work, and it created a real barrier. Every time I had an idea for a new tool, the mental cost of &ldquo;copy four files, wire them up, fix the imports&rdquo; made me hesitate. Some ideas never got past that hesitation.</p>
<h2 id="what-it-costs-now">What It Costs Now</h2>
<p>After extracting <code>local-first-common</code>, starting a new tool looks like this:</p>
<!-- test:skip -->
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv init my-new-tool
</span></span><span class="line"><span class="cl">uv add <span class="s2">&#34;local-first-common @ git+https://github.com/jamalhansen/local-first-common.git@main&#34;</span>
</span></span></code></pre></div><p>And then, in the code:</p>
<!-- test:skip -->
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">local_first_common.providers</span> <span class="kn">import</span> <span class="n">PROVIDERS</span><span class="p">,</span> <span class="n">resolve_provider</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">local_first_common.cli</span> <span class="kn">import</span> <span class="n">add_provider_args</span>
</span></span></code></pre></div><p>That&rsquo;s it. Two commands, two imports. The provider abstraction, CLI helpers, Obsidian utilities, and <code>MockProvider</code> for testing are all available. The only code left to write is the code that makes this tool different from every other tool.</p>
<p>The shift is significant. The cost of starting a new project dropped from &ldquo;an afternoon of boilerplate&rdquo; to &ldquo;five minutes of setup.&rdquo; When the startup cost is that low, ideas that used to stall in the &ldquo;maybe someday&rdquo; pile actually get built.</p>
<p>Which brings me to the queue.</p>
<h2 id="the-read-later-queue">The Read-Later Queue</h2>
<p>My Obsidian vault has a read-later system. Articles get tagged, scored by my <a href="https://jamalhansen.com/blog/the-content-curator/">content discovery tool</a>, and queued for review. The problem is that &ldquo;review&rdquo; currently means me opening each article, reading it, deciding if it&rsquo;s worth keeping, and writing a note about why.</p>
<p>That&rsquo;s fine for five articles a week. It&rsquo;s not fine for thirty.</p>
<p>The tool I want to build is a genuine multi-step agent. Not a single LLM call that summarizes an article. A workflow that makes decisions:</p>
<ol>
<li>Fetch the article and read it</li>
<li>Summarize the key ideas</li>
<li>Cross-reference against what&rsquo;s already in my vault (do I already have notes on this topic?)</li>
<li>Annotate the article with connections to existing notes</li>
<li>Decide: keep, archive, or discard, with a reason</li>
</ol>
<p>Steps 1 and 2 are straightforward. Steps 3 through 5 are where it gets interesting, because they require the tool to know what&rsquo;s in the vault, make a judgment call, and explain that judgment in a way I can review.</p>
<p>This is different in kind from the tools I&rsquo;ve built so far. The promo generator takes input and produces output. The transcription summarizer does the same. They&rsquo;re single-shot: content in, content out. The read-later agent needs to hold state, query external sources, and make decisions that depend on context it has to go find.</p>
<p>I don&rsquo;t know exactly what this looks like yet, and I&rsquo;m not going to over-specify it here. The details will change when I actually build it. But the shared library means I can focus entirely on the agentic workflow without spending a day on provider abstraction and CLI setup first.</p>
<h2 id="the-weekly-digest">The Weekly Digest</h2>
<p>The other project I&rsquo;m thinking about is a cross-project weekly digest. I have three tools that produce content on three different schedules:</p>
<p><strong>Voice memos</strong> happen whenever I think of something. Could be twice a day, could be nothing for a week. The transcription summarizer cleans them up and writes them to my vault, but there&rsquo;s no rhythm to when they arrive.</p>
<p><strong>Daily notes</strong> happen daily (obviously). They capture what I worked on, what I&rsquo;m thinking about, and what&rsquo;s coming up. They&rsquo;re structured and predictable.</p>
<p><strong>Content scoring</strong> happens on a schedule. The content discovery tool runs periodically, scores articles, and surfaces the ones worth reading. The cadence is regular but doesn&rsquo;t align with the other two.</p>
<p>The digest problem is: take these three streams, which don&rsquo;t line up in time or structure, and produce one coherent weekly narrative. What did I think about this week? What did I read that mattered? What themes keep showing up across voice memos, daily notes, and content scores?</p>
<p>This is a coordination problem, not just a summarization problem. A tool that concatenates three outputs and asks an LLM to summarize them would produce mush. The interesting work is in figuring out which pieces from each source actually connect, and presenting them in a way that tells me something I didn&rsquo;t already know.</p>
<h2 id="the-queue-is-long">The Queue Is Long</h2>
<p>I&rsquo;m not going to list all 26 tool ideas. A list that long is immediately out of date, and half of them won&rsquo;t survive contact with reality. What matters is that the list exists and keeps growing.</p>
<p>Before the shared library, a long ideas list felt like debt. Every item was a project that would take a day of boilerplate before I could start on the interesting part. Now that the startup cost is five minutes, a long list feels like momentum. The infrastructure is in place. The ideas just need time.</p>
<p>Some of them will turn into posts in this series. Some will get built and quietly used without anyone hearing about them. Some will turn out to be bad ideas that die in the first hour. That&rsquo;s fine. The cost of finding out is low enough now that it&rsquo;s worth trying.</p>
<h2 id="what-this-arc-was-about">What This Arc Was About</h2>
<p>This is the last post in the Architecture Arc. Looking back, the through-line is:</p>
<ol>
<li><a href="https://jamalhansen.com/blog/copy-and-paste-long-enough-and-the-architecture-appears/">Copy-paste long enough and the real abstraction appears</a></li>
<li><a href="https://jamalhansen.com/blog/how-i-extracted-a-shared-library/">Extract the shared parts into a library</a></li>
<li><a href="https://jamalhansen.com/blog/gemini-forgot-its-plan-i-kept-the-makefile/">Even messy sessions produce useful architecture</a></li>
<li>The architecture pays for itself by making the next thing cheaper to build</li>
</ol>
<p>Vibe coding didn&rsquo;t replace the engineering work. It accelerated the part where you figure out what engineering work is worth doing. The repetition wasn&rsquo;t waste. The messy sessions weren&rsquo;t failure. The growing queue isn&rsquo;t debt. They&rsquo;re all evidence that the system is working.</p>
]]></content:encoded></item><item><title>Gemini Forgot Its Plan. I Kept the Makefile.</title><link>https://jamalhansen.com/blog/gemini-forgot-its-plan-i-kept-the-makefile/</link><pubDate>Sat, 23 May 2026 00:00:00 +0000</pubDate><guid>https://jamalhansen.com/blog/gemini-forgot-its-plan-i-kept-the-makefile/</guid><description>I ran out of Claude tokens and handed my six-repo codebase to Gemini CLI. It made some mistakes and had one genuinely good idea.</description><content:encoded><![CDATA[<p>I was in the middle of the <a href="https://jamalhansen.com/blog/how-i-extracted-a-shared-library/">shared library extraction</a> when I ran out of Claude tokens. Six repos deep, changes half-committed, migration plan half-finished. This was not ideal timing.</p>
<p>So I opened Gemini CLI and handed it the plan.</p>
<p>What followed was messy, instructive, and produced one idea that was better than anything I&rsquo;d come up with on my own.</p>
<h2 id="running-out-of-tokens-at-the-worst-possible-moment">Running Out of Tokens at the Worst Possible Moment</h2>
<p>Context windows and token budgets are real constraints when you&rsquo;re vibe-coding, especially across multiple repositories. A six-repo migration with shared dependencies, coordinated test suites, and a consolidation plan burns through tokens quickly. This was also before I slimmed down the context I sent with every prompt.</p>
<p>Switching models mid-project felt like a reasonable experiment. Gemini has a massive context window, and I&rsquo;ve seen it do some impressive work in the past, so I handed the project over to it.</p>
<h2 id="what-gemini-did-and-what-it-forgot">What Gemini Did (and What It Forgot)</h2>
<p>I gave Gemini the consolidation plan. Thankfully, I instructed Claude to write it to a file before I ran out of tokens. I find that it helps to have the plan documented because sometimes the thread gets lost during implementation. The plan was specific: here are the six repos, here are the four modules being extracted into <code>local-first-common</code>, here are the repos that still need migration, here&rsquo;s the order.</p>
<p>Gemini started strong. It understood the structure, identified the right files, the current status, and began making changes.</p>
<p>Then it lost the thread.</p>
<p>About a third of the way through, I noticed it was working on a repo that was already migrated. I reminded it where we were in the plan. It recovered, kept going, and made progress on the next repo.</p>
<p>Then it lost the thread again.</p>
<p>This time it wasn&rsquo;t just confused about which repo was next. It had forgotten the plan entirely. When I asked &ldquo;what are we working on?&rdquo;, it gave me a plausible-sounding summary that didn&rsquo;t match the actual consolidation plan at all. It had constructed a new plan from context clues, and the new plan was wrong.</p>
<p>This is a specific failure mode worth naming: a model with a long context window doesn&rsquo;t automatically track its own multi-step plan. Having the tokens available to hold the plan and having the ability to follow it are two different things. A big context window is storage, not project management. I found myself repeatedly pointing back to that plan.</p>
<h2 id="the-broken-tests-and-the-declaration-of-victory">The Broken Tests and the Declaration of Victory</h2>
<p>After some gentle reminding, Gemini got back on track and pushed through the remaining repos. At the end, it gave me a summary: all repos migrated, tests passing, consolidation complete.</p>
<p>Except the tests weren&rsquo;t passing.</p>
<p>I ran the test suites myself. Two repos had broken tests. Not complicated failures. The imports pointed to the old local module paths instead of <code>local_first_common</code>. The kind of thing you catch if you run tests after making changes.</p>
<p>Gemini had committed the code and marked the task done without verifying. It declared victory based on what it held onto of the plan, not on whether the results worked or they matched the acceptance criteria.</p>
<p>Fixing the imports and validating the tests isn&rsquo;t a huge deal. The damage was small in this case. But the pattern matters: &ldquo;AI said it&rsquo;s done&rdquo; and &ldquo;the work is done&rdquo; are not the same thing. The lesson was becoming clear. If you&rsquo;re vibe coding and the model tells you everything is green, run the tests yourself.</p>
<h2 id="one-really-good-idea">One Really Good Idea</h2>
<p>The session was messier than a Claude session would have been. The context loss was frustrating. The broken tests were sloppy. But buried in the middle of the work, Gemini produced something I hadn&rsquo;t asked for and wouldn&rsquo;t have thought to build: a Makefile.</p>
<p>The Makefile wasn&rsquo;t configured to build code like its original intention. This Makefile existed to run repeated tasks across each of the multiple repositories.</p>
<p>The idea was simple. Each repo lives in a sub-directory. A Makefile with the right targets can check the status of every repo, run every test suite, and flag anything that needs attention. All from one command. It was a genuinely smart use of the tool.</p>
<p>Here&rsquo;s roughly what it looked like:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nv">REPOS</span> <span class="o">=</span> promo-generator blog-reviewer content-discovery <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>        transcription-summarizer daily-note-summarizer baby-journal
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">.PHONY</span><span class="o">:</span> <span class="n">status</span> <span class="n">test</span>-<span class="n">all</span> <span class="n">check</span>-<span class="n">dirty</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">status</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	@for repo in <span class="k">$(</span>REPOS<span class="k">)</span><span class="p">;</span> <span class="k">do</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		<span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="nv">$$</span><span class="s2">repo ===&#34;</span><span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		git -C <span class="nv">$$</span>repo status --short<span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>	<span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">test-all</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	@for repo in <span class="k">$(</span>REPOS<span class="k">)</span><span class="p">;</span> <span class="k">do</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		<span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="nv">$$</span><span class="s2">repo ===&#34;</span><span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		<span class="o">(</span><span class="nb">cd</span> <span class="nv">$$</span>repo <span class="o">&amp;&amp;</span> uv run pytest -q 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -1<span class="o">)</span><span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>	<span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">check-dirty</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	@for repo in <span class="k">$(</span>REPOS<span class="k">)</span><span class="p">;</span> <span class="k">do</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		<span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$$</span><span class="s2">(git -C </span><span class="nv">$$</span><span class="s2">repo status --porcelain)&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>			<span class="nb">echo</span> <span class="s2">&#34;DIRTY: </span><span class="nv">$$</span><span class="s2">repo&#34;</span><span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		<span class="k">fi</span><span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>	<span class="k">done</span>
</span></span></code></pre></div><p><code>make status</code> shows you every repo&rsquo;s git status. <code>make test-all</code> runs every test suite and shows the summary line. <code>make check-dirty</code> tells you which repos have uncommitted changes.</p>
<p>Three targets. Each one does one thing. Together they give you a dashboard for a multi-repo project.</p>
<h2 id="atoms-molecules-organisms">Atoms, Molecules, Organisms</h2>
<p>This is where the idea gets interesting. If you&rsquo;ve worked in front-end development, you might recognize the term atomic design pattern. This pattern promotes the use of small, self-contained pieces (atoms) that compose into larger groups (molecules), which compose into full interfaces (organisms).</p>
<p>The Makefile does the same thing for a multi-repo codebase.</p>
<p><strong>Atoms</strong> are the individual operations. Check one repo&rsquo;s git status. Run one repo&rsquo;s test suite. See if one repo has uncommitted changes. Each one is a single shell command.</p>
<p><strong>Molecules</strong> are the targets. <code>make status</code> loops an atom across all six repos. <code>make test-all</code> does the same for tests. Each target composes atoms into a useful answer to a single question.</p>
<p><strong>Organisms</strong> are the workflows that chain targets together. &ldquo;Show me everything that needs attention before I commit&rdquo; is <code>make check-dirty</code> followed by <code>make test-all</code>. An AI coding assistant can run these without needing to be told which repos exist or how to check them. The Makefile encodes that knowledge once.</p>
<p>This matters for vibe coding specifically. When you&rsquo;re working with an AI that loses context (and they all do eventually), having a coordination layer that doesn&rsquo;t depend on the model&rsquo;s memory changes everything. The Makefile remembers the project structure and how to validate, so the model doesn&rsquo;t have to. I still use it.</p>
<h2 id="what-i-took-away">What I Took Away</h2>
<p>The Gemini experiment taught me three things:</p>
<p><strong>A big context window is not a plan.</strong> Long-context models can hold your entire codebase. That doesn&rsquo;t mean they can track a multi-step migration across it. If you&rsquo;re doing complex multi-repo work, give the model an external artifact to follow: a spec file, a checklist, a markdown document it can re-read. Don&rsquo;t rely on it to hold the plan in its head.</p>
<p><strong>Verify independently, every time.</strong> When a model tells you the work is done, that&rsquo;s a claim, not a fact. Run the tests. Check the imports. Read the diff. The ten minutes you spend verifying is cheaper than the hour you&rsquo;ll spend debugging a broken commit you trusted.</p>
<p><strong>Good ideas come from unexpected places.</strong> The Makefile wasn&rsquo;t in the plan. It came from a model that couldn&rsquo;t reliably follow a plan. But the idea of a coordination layer that encodes project structure so the model doesn&rsquo;t have to remember it? That&rsquo;s genuinely useful. It survived the session (barely), and it&rsquo;s now part of how I work.</p>
<p>The session was messy, but it produced a cleaner architecture and a great idea. I&rsquo;ll take it.</p>
]]></content:encoded></item><item><title>I Extracted a Shared Library and Got 400 Tests I Didn't Ask For</title><link>https://jamalhansen.com/blog/i-extracted-a-shared-library-and-got-400-tests-i-didnt-ask-for/</link><pubDate>Fri, 10 Apr 2026 00:00:00 +0000</pubDate><guid>https://jamalhansen.com/blog/i-extracted-a-shared-library-and-got-400-tests-i-didnt-ask-for/</guid><description>Six Python projects, four duplicated files, one shared library. The code behind the extraction, what moved, and the surprise that made it worth it.</description><content:encoded><![CDATA[<p><a href="https://jamalhansen.com/blog/copy-and-paste-long-enough-and-the-architecture-appears/">Last time</a> I argued that you can&rsquo;t design your way to a good abstraction. You have to earn it through repetition. Here&rsquo;s what that actually looked like.</p>
<p>I had six Python projects, each containing its own version of the same four files:</p>
<ul>
<li>A provider abstraction for talking to LLMs</li>
<li>CLI argument helpers</li>
<li>Obsidian utilities for reading and writing notes</li>
<li>A testing module for stubbing out model calls</li>
</ul>
<p>I knew that I wasn&rsquo;t sharing code between the tools and that each would have similar needs. But it wasn&rsquo;t my priority to fix, so I let it happen. And the code accumulated, one project at a time, each one re-creating a variation on the same logic. Like a lazy developer, copy-pasting code from another repository and tweaking it to fit.</p>
<p>Here&rsquo;s how I extracted the shared logic into a shared library called <code>local-first-common</code>: what moved, what didn&rsquo;t, and the side effect that made the whole thing worth it.</p>
<h2 id="four-files-six-times">Four Files, Six Times</h2>
<p>Every tool in my workflow talks to LLMs. That means every tool needs:</p>
<p><strong><code>providers/</code></strong> - An abstract <code>BaseProvider</code> class with a <code>complete()</code> method, plus concrete implementations for Ollama, Anthropic, Groq, and DeepSeek. Local models are the priority, but some aren&rsquo;t capable enough on their own, so cloud providers matter for comparison. Each provider knows its default model, its list of known models, and where to find the full catalog online.</p>
<p><strong><code>cli.py</code></strong> - Shared argument definitions for <code>--provider</code>, <code>--model</code>, <code>--verbose</code>, and <code>--dry-run</code>. A <code>resolve_provider()</code> function that takes the provider name and optional model override, instantiates the right class, and hands it back ready to use.</p>
<p><strong><code>obsidian.py</code></strong> - Most of my tools read from or write to an Obsidian vault. This handles vault path discovery, frontmatter reading and writing, and note updates.</p>
<p><strong><code>testing.py</code></strong> - A <code>MockProvider</code> that returns canned responses without hitting any API. This one turned out to matter more than I expected.</p>
<p>Six projects. Four duplicated modules. All slightly different. That&rsquo;s the starting point.</p>
<h2 id="three-ways-to-share-code-one-that-fits">Three Ways to Share Code (One That Fits)</h2>
<p>Once I decided to extract a shared library, the question was how to distribute it. I had three options.</p>
<p><strong>Monorepo.</strong> Put everything in one repository. It probably would have been easier if I had chosen this route, especially at the beginning. I had 26 tool ideas in the backlog, and the list was growing. As I write this, the list is up to 42 tools, and I am questioning my sanity. Cramming all of this into one repo would mean every tool shares a commit history, a CI pipeline, and a release cycle with every other tool. I wasn&rsquo;t willing to do that.</p>
<p><strong>Publish to PyPI.</strong> Clearly, this is the &ldquo;real&rdquo; way to share a Python package. But publishing to a registry means maintaining version numbers, writing a release workflow, and dealing with authentication. For a library that has exactly one consumer (me), that&rsquo;s way too much overhead.</p>
<p><strong>Git URL dependency.</strong> Point each tool&rsquo;s <code>pyproject.toml</code> at the shared library&rsquo;s GitHub repo. No registry. No release pipeline. Just a URL.</p>
<p>Here&rsquo;s what that looks like in the <code>pyproject.toml</code>:</p>
<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">tool</span><span class="p">.</span><span class="nx">uv</span><span class="p">.</span><span class="nx">sources</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">local-first-common</span> <span class="p">=</span> <span class="p">{</span> <span class="nx">git</span> <span class="p">=</span> <span class="s2">&#34;https://github.com/jamalhansen/local-first-common.git&#34;</span><span class="p">,</span> <span class="nx">rev</span> <span class="p">=</span> <span class="s2">&#34;main&#34;</span> <span class="p">}</span>
</span></span></code></pre></div><p>Run <code>uv sync</code>, and the library installs directly from the repo. When I push a change to <code>local-first-common</code>, each tool picks it up on its next sync.</p>
<p>This is the middle path. Less overhead than PyPI, less coupling than a monorepo. For personal tooling, it is the right trade-off.</p>
<h3 id="local-development-override">Local Development Override</h3>
<p>There&rsquo;s one big problem with this solution. When you&rsquo;re actively working on the shared library, you don&rsquo;t want to push a commit and re-sync every time you change a line. The fix is a <code>pyproject.toml</code> file that swaps the git reference for a local editable path.</p>
<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">tool</span><span class="p">.</span><span class="nx">uv</span><span class="p">.</span><span class="nx">sources</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">local-first-common</span> <span class="p">=</span> <span class="p">{</span><span class="nx">path</span> <span class="p">=</span> <span class="s2">&#34;../local-first-common&#34;</span><span class="p">,</span> <span class="nx">editable</span> <span class="p">=</span> <span class="kc">true</span><span class="p">}</span>
</span></span></code></pre></div><h2 id="what-moved-and-what-didnt">What Moved (and What Didn&rsquo;t)</h2>
<p>The migration itself was straightforward, especially with the help of Claude Code:</p>
<ol>
<li>Create the <code>local-first-common</code> repo</li>
<li>Move the four modules in</li>
<li>Update <code>pyproject.toml</code> in each tool</li>
<li>Delete the local copies</li>
<li>Run the tests</li>
<li>Fix anything that broke</li>
</ol>
<p>One interesting part was what didn&rsquo;t migrate.</p>
<p>One of the tools is a privacy-minded baby journal. It seemed to be a strong candidate for using this shared code. It reads and writes markdown. It discovers files in a directory. It serializes structured data into notes. All of that sounds like overlap with <code>obsidian.py</code>.</p>
<p>But none of it moved. The baby journal&rsquo;s file discovery uses TOML config to find its data directory, not Obsidian vault detection. Its note serialization is tied to a typed <code>JournalEntry</code> model with fields like <code>mood</code>, <code>milestones</code>, and <code>photo_paths</code>. Its LLM-based auto-tagging and photo handling have no equivalent in any other tool, and its privacy-first stance didn&rsquo;t benefit from gaining access to cloud models.</p>
<p>The utilities looked similar from a distance. Up close, they were solving different problems. If I&rsquo;d forced them into the shared library up front, I would have been extracting noise, not signal, and adding unneeded complexity to the shared code.</p>
<p>That false negative made the abstraction boundary real. The things that didn&rsquo;t move validate the things that did.</p>
<h2 id="the-before-and-after-promo-generator">The Before and After: Promo Generator</h2>
<p>The promo-generator is the clearest example of what the migration looked like in practice. Before the migration, it hardcoded the <code>ollama</code> Python package with a fixed model. It looked something like this:</p>
<!-- test:setup -->
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">provider_name</span> <span class="o">=</span> <span class="s2">&#34;ollama&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">model</span> <span class="o">=</span> <span class="s2">&#34;llama3.2&#34;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">ollama</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">generate_promo</span><span class="p">(</span><span class="n">content</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">platform</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">ollama</span><span class="o">.</span><span class="n">generate</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">model</span><span class="o">=</span><span class="s2">&#34;llama3.2&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">prompt</span><span class="o">=</span><span class="sa">f</span><span class="s2">&#34;Write a </span><span class="si">{</span><span class="n">platform</span><span class="si">}</span><span class="s2"> promo for this blog post:</span><span class="se">\n\n</span><span class="si">{</span><span class="n">content</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">response</span><span class="p">[</span><span class="s2">&#34;response&#34;</span><span class="p">]</span>
</span></span></code></pre></div><p>After the migration, it uses the shared provider abstraction:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">local_first_common.cli</span> <span class="kn">import</span> <span class="n">resolve_provider</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">local_first_common.providers</span> <span class="kn">import</span> <span class="n">PROVIDERS</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">generate_platform</span><span class="p">(</span><span class="n">platform_key</span><span class="p">,</span> <span class="n">platform</span><span class="p">,</span> <span class="n">provider</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="n">config</span><span class="p">,</span> <span class="n">verbose</span><span class="p">,</span> <span class="n">dry_run</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">system</span> <span class="o">=</span> <span class="n">build_system_prompt</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">prompt</span> <span class="o">=</span> <span class="n">build_platform_prompt</span><span class="p">(</span><span class="n">platform</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="n">config</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">provider</span><span class="o">.</span><span class="n">complete</span><span class="p">(</span><span class="n">system</span><span class="p">,</span> <span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">result</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the CLI</span>
</span></span><span class="line"><span class="cl"><span class="n">provider</span> <span class="o">=</span> <span class="n">resolve_provider</span><span class="p">(</span><span class="n">PROVIDERS</span><span class="p">,</span> <span class="n">provider_name</span><span class="p">,</span> <span class="n">model</span><span class="p">)</span>
</span></span></code></pre></div><p>The function no longer knows or cares which LLM it&rsquo;s talking to. Ollama, Anthropic, Groq, DeepSeek, they all work. The provider is chosen at the command line with <code>--provider</code> and <code>--model</code>, same as other tools.</p>
<p>That&rsquo;s an improvement, but it&rsquo;s not the best part.</p>
<h2 id="the-thing-i-didnt-ask-for">The Thing I Didn&rsquo;t Ask For</h2>
<p>The best part was testability.</p>
<p>Before the migration, promo-generator had zero tests. Every function that did anything interesting called an LLM. Patching out a third-party SDK call is tedious and fragile, even with a coding agent at your disposal.</p>
<p><code>local-first-common</code> now includes <code>MockProvider</code>, a provider that returns canned responses without hitting any API or model. After the migration, testing an LLM-powered function looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">local_first_common.testing</span> <span class="kn">import</span> <span class="n">MockProvider</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">test_generate_platform</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span> <span class="o">=</span> <span class="s2">&#34;https://jamalhansen.com/blog/my-post&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">provider</span> <span class="o">=</span> <span class="n">MockProvider</span><span class="p">(</span><span class="n">response</span><span class="o">=</span><span class="sa">f</span><span class="s2">&#34;Check out this post.</span><span class="se">\n</span><span class="si">{</span><span class="n">url</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">generate_platform</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;twitter&#34;</span><span class="p">,</span> <span class="n">SAMPLE_PLATFORM</span><span class="p">,</span> <span class="n">provider</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">url</span><span class="p">,</span> <span class="s2">&#34;HOOK: Something&#34;</span><span class="p">,</span> <span class="p">{},</span> <span class="n">verbose</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">dry_run</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">result</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="nb">len</span><span class="p">(</span><span class="n">provider</span><span class="o">.</span><span class="n">calls</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span>
</span></span></code></pre></div><p>No HTTP mocking. No patching. No fixtures. You pass in a <code>MockProvider</code> and get a predictable response.</p>
<p>Promo-generator went from 0 tests to 33 after the migration. Not because the logic changed. The logic was already there. The migration made it testable.</p>
<p>Across all seven repos, I ended up with over 400 passing tests. That&rsquo;s a nice number, but the number isn&rsquo;t the point. Good architecture and good testability are the same thing. When your functions take their dependencies as arguments instead of reaching out to global state, testing becomes trivial. The shared library made that pattern the default.</p>
<h2 id="whats-in-the-box">What&rsquo;s in the Box</h2>
<p><code>local-first-common</code> has four modules:</p>
<ul>
<li><strong><code>providers/</code></strong> - <code>BaseProvider</code> abstract class, plus <code>OllamaProvider</code>, <code>AnthropicProvider</code>, <code>GroqProvider</code>, <code>DeepSeekProvider</code>, and <code>GeminiProvider</code>. A <code>PROVIDERS</code> dict mapping names to classes, and <code>resolve_provider()</code> for instantiating the right one from CLI flags.</li>
<li><strong><code>obsidian.py</code></strong> - Vault path discovery, frontmatter reading and writing, note creation helpers.</li>
<li><strong><code>cli.py</code></strong> - Shared argument definitions for <code>--provider</code>, <code>--model</code>, <code>--verbose</code>, and <code>--dry-run</code>. Works with argparse, Typer, or Click.</li>
<li><strong><code>testing.py</code></strong> - <code>MockProvider</code> for stubbing LLM calls in tests without patching anything.</li>
</ul>
<p>Four modules at the time of extraction. The library has grown since, and it now has utilities for tracking, social posting, HTTP helpers, and more. Those came later, one tool at a time, the same way the original four did.</p>
<h2 id="the-takeaway">The Takeaway</h2>
<p>Don&rsquo;t extract early. The pattern has to repeat enough times that you actually know what the pattern is. If I&rsquo;d pulled a shared library out after two tools, I would have extracted the wrong thing.</p>
<p>Git URL dependencies are the right middle ground for personal tooling. No registry. No release pipeline. No version numbers to maintain. Push to the repo, run <code>uv sync</code>, move on.</p>
<p>Pay attention to what doesn&rsquo;t migrate. If everything moves, you didn&rsquo;t find a real abstraction. The baby journal not moving validated everything that did.</p>
<p>The testability wasn&rsquo;t something I planned. It was a consequence of the architecture. When functions take their dependencies as arguments instead of reaching out to global state, tests become straightforward to write. The shared library made that the default pattern across all the tools.</p>
<p>Next time, BartBot takes the keyboard. The topic is institutional memory, skill files, and the mild strangeness of an AI writing instructions for itself.</p>
]]></content:encoded></item><item><title>Copy and Paste Long Enough and the Architecture Appears</title><link>https://jamalhansen.com/blog/copy-and-paste-long-enough-and-the-architecture-appears/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://jamalhansen.com/blog/copy-and-paste-long-enough-and-the-architecture-appears/</guid><description>You can&amp;#39;t design your way to a good abstraction. You have to earn it through repetition. Vibe coding made that repetition happen faster, not slower.</description><content:encoded><![CDATA[<p>Ever find yourself writing the same code in a different repo? I have. What did you do about it?</p>
<p>Maybe your first reaction is to reach for an existing library to do the work for you? Or perhaps you start thinking about the DRY principle and how you need to start optimizing and combining your code.</p>
<p>I&rsquo;m up to 16 different repositories, each containing a tool that I&rsquo;ve vibe-coded with some help from Claude Code and/or Gemini. Things like:</p>
<ul>
<li>A 100% local baby journal with auto-tagging</li>
<li>An opinionated voice transcription summarizer</li>
<li>A <a href="https://jamalhansen.com/blog/the-content-curator/">content discovery agent</a></li>
<li>A blog post draft feedback tool</li>
</ul>
<p>These tools are built so that they can use a local LLM from Ollama, and easily switch it out for another one, or a frontier cloud model for comparison and experimentation. As I&rsquo;ve mentioned, <a href="https://jamalhansen.com/blog/why-i-run-ai-locally-and-you-might-want-to/">I&rsquo;m looking for that sweet spot where local models are a better choice</a> than a cloud model.</p>
<p>As you can imagine, each tool rewrites a lot of the same code. Just like human developers, when AI reimplements the same requirement, it comes out a little different each time. Maybe the needs of the tool are a bit different. It could just be that it decided to take a slightly different approach.</p>
<p>By the time I reached three repositories, the Don&rsquo;t Repeat Yourself (DRY) alarm was already ringing in my head, and I was considering consolidating code bases. But I didn&rsquo;t.</p>
<p>I waited until I got to about six repositories. That&rsquo;s lots of repetition. Each time it was slightly (or occasionally wildly) different. You might call it reckless or a waste of tokens.</p>
<p>That patience paid off. It took time and repetition before the real abstraction emerged. If you&rsquo;d asked me after the first project to design the shared version, I would have gotten it wrong. I know this because I would have designed for the shape of that one project, not the shape of the actual problem.</p>
<h2 id="the-conventional-wisdom-is-backwards">The Conventional Wisdom Is Backwards</h2>
<p>Everyone assumes that vibe coding with AI makes premature abstraction more tempting. And on paper, that makes sense. It&rsquo;s trivially easy to ask an LLM to scaffold a shared library on day one. You could have your abstract base class, your plugin registry, your configuration schema. All before writing a single line of real business logic.</p>
<p>But that&rsquo;s not what happened. The actual experience was the opposite.</p>
<p>AI made the copy-paste period faster. I could spin up a new tool in an afternoon instead of a week. That meant I hit &ldquo;enough repetition to see the real pattern&rdquo; sooner, not later. Vibe coding didn&rsquo;t eliminate the need to earn the abstraction. It compressed the timeline for earning it.</p>
<h2 id="dry-is-discovery-not-discipline">DRY Is Discovery, Not Discipline</h2>
<p>The conventional framing of DRY is moral. Don&rsquo;t Repeat Yourself. It sounds like a rule you follow out of professionalism, like washing your hands or writing unit tests.</p>
<p>The real value of DRY is epistemic. Repetition teaches you what&rsquo;s actually repeated. You can&rsquo;t skip the repetition and still get the learning.</p>
<p>Think of it this way. If you write a function once, you know what that function does. If you write a similar function three times across three projects, you start to see which parts are truly shared and which parts only looked shared because the first project was the only context you had.</p>
<p>The repetition is the research. Skipping it means guessing.</p>
<h2 id="build-three-things-then-extract">Build Three Things, Then Extract</h2>
<p>If you design a shared library before building the tools that use it, you&rsquo;re guessing at the interface. You might get close. You might even get lucky. But you&rsquo;re still working from theory.</p>
<p>If you build three tools and then extract the shared parts, the interface is revealed by use. The real API is the one that three different callers independently needed. Not the one you imagined on a whiteboard.</p>
<p>This is what happened with my projects. I built a promo generator, a blog reviewer, a content discovery tool, a transcription summarizer, a daily note summarizer, and a baby journal app. Each one talked to LLMs. Each one had some kind of provider abstraction. Each one handled CLI arguments.</p>
<p>The overlaps were real, but they weren&rsquo;t where I expected them to be.</p>
<h2 id="what-moved-and-what-didnt">What Moved and What Didn&rsquo;t</h2>
<p>Here&rsquo;s the part that surprised me. When I finally sat down to extract a shared library, some things moved cleanly and some didn&rsquo;t move at all.</p>
<p>The <code>BaseProvider</code> pattern (an abstract class for talking to LLMs) appeared in six projects with many slightly different shapes.</p>
<p>blog-reviewer and content-discovery were nearly identical. daily-note-summarizer added Pydantic schema support for structured responses. transcription-summarizer was simpler. The drift between them told me exactly where the boundaries were.</p>
<p>The CLI frameworks told their own story. Typer, argparse, and Click were all used. In some cases, you can see why a particular library was chosen based on what the tool needed when it was built. That inconsistency isn&rsquo;t a mistake. It&rsquo;s evidence of real decisions made in real contexts.</p>
<p>One tool, the baby journal, was a bit different from the others. It valued privacy above all. It read and wrote markdown like other tools, but the markdown it produced <em>was</em> the journal itself, not notes about something stored elsewhere. Because of this, the utilities in the codebase looked like overlap with the other tools at first glance. Markdown discovery, note serialization, file handling. But when I tried to extract them, they didn&rsquo;t move. The vault discovery used TOML config. The note serialization was tied to a typed <code>JournalEntry</code> model. The LLM-based tagging and photo handling were completely unique.</p>
<p>That&rsquo;s not a failure. That&rsquo;s the abstraction having a real boundary. If everything had moved, I would have been extracting noise. The things that didn&rsquo;t move prove the things that did were signal.</p>
<h2 id="the-26-tool-constraint">The 26-Tool Constraint</h2>
<p>The same thing happened at the project level.</p>
<p>At some point I had to decide: monorepo or separate repos? I had 26 tool ideas on a list. Too many to consolidate into one repository. That constraint forced each tool into its own repo, with shared code installed as a dependency.</p>
<p>Turns out that&rsquo;s a better architecture for this use case than a monorepo would have been. Each tool stays independent. Shared code evolves through explicit versioning. Nothing is coupled that doesn&rsquo;t need to be.</p>
<p>I never would have made that call on project one. It took building enough tools to have 26 ideas before the right structure became obvious.</p>
<h2 id="earning-it">Earning It</h2>
<p>The lesson is simple, even if it feels counterintuitive. You can&rsquo;t design your way to a good abstraction. You have to build enough things that the abstraction reveals itself.</p>
<p>AI didn&rsquo;t change that. It just made the building faster. Sixteen tools in a few months instead of sixteen tools in a few years. The copy-paste happened at higher speed, and the architecture appeared on a shorter timeline.</p>
<p>If you&rsquo;re vibe coding and your projects feel repetitive, that&rsquo;s not a problem to solve. That&rsquo;s the process working. Keep building. The patterns will show up when they&rsquo;re ready.</p>
<p>Next time, I&rsquo;ll show you the technical details of what the shared library actually looks like and how I extracted it. The code behind the argument.</p>
<p>Have you ever built the same thing three times before you understood what it really was? I&rsquo;d love to hear about it.</p>
]]></content:encoded></item><item><title>AWS Card Clash - Gen AI Battles</title><link>https://jamalhansen.com/blog/aws-card-clash-gen-ai-battles-demo/</link><pubDate>Wed, 07 May 2025 00:00:00 +0000</pubDate><guid>https://jamalhansen.com/blog/aws-card-clash-gen-ai-battles-demo/</guid><description>Today I joined a demo of the AWS Card Clash architecture design game available on AWS SkillBuilder</description><content:encoded><![CDATA[<p>Today I joined the <a href="https://www.linkedin.com/events/awscardclash-genaibattles-s1e1-7319012363007897600/theater/">AWS Card Clash: Gen AI Battles | S1 E1 | Model Access Showdown</a> on LinkedIn. This session demoed the AWS Card Clash learning game available on AWS SkillBuilder. The session was hosted by <a href="https://www.linkedin.com/in/curtisdmorton/">Curtis Morton</a>, <a href="https://www.linkedin.com/in/jasoncuddy/">Jason Cuddy</a>, and <a href="https://www.linkedin.com/in/brittany-wolfrom/">Brittany Wolfrom</a>.</p>
<p>AWS Card Clash is a fun and engaging card game that pits two players against each other to complete architecture diagrams.</p>
<p>Some of the things that I liked about this as a learning activity are:</p>
<ul>
<li>The diagrams are actual architectures that you might use in AWS</li>
<li>The actual symbols for the different services are used to help identify them</li>
<li>There are multiple ways to access additional information about each service to better understand it</li>
<li>The security feature used to protect your plays are actual AWS security services.</li>
</ul>
<p>Some of the things that I liked about this as a game are:</p>
<ul>
<li>The are single and multiplayer versions of the game</li>
<li>It uses cards, energy management, and other game elements in the game flow</li>
<li>It can be played by players who are beginning their AWS learning journey</li>
<li>There is a potential to use strategy rather than just AWS knowledge to win</li>
</ul>
<p>Overall this was a great session and I have already run through a trial game on my own to better understand how it works. I expect to be playing more of this and hope to be able to play the multiplayer mode soon.</p>
]]></content:encoded></item></channel></rss>