<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.softwarewrighter.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.softwarewrighter.com/" rel="alternate" type="text/html" /><updated>2026-09-08T13:35:53-07:00</updated><id>https://blog.softwarewrighter.com/feed.xml</id><title type="html">Software Wrighter Lab Blog</title><subtitle>AI coding agents, systems programming, and practical machine learning</subtitle><author><name>Mike Wright</name></author><entry><title type="html">Energy-Based Learning: From Hopfield Networks to JEPA</title><link href="https://blog.softwarewrighter.com/2026/05/16/energy-based-learning-hopfield-boltzmann-jepa/" rel="alternate" type="text/html" title="Energy-Based Learning: From Hopfield Networks to JEPA" /><published>2026-05-16T00:15:00-07:00</published><updated>2026-05-16T00:15:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/05/16/energy-based-learning-hopfield-boltzmann-jepa</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/05/16/energy-based-learning-hopfield-boltzmann-jepa/"><![CDATA[<p><img src="/assets/images/posts/block-3d-energy-landscape.webp" class="post-marker no-invert" alt="" style="width: 260px;" /></p>

<p>JEPA can sound like a sudden new architecture: predict hidden pieces of the world in representation space, avoid pixel reconstruction, learn useful abstractions, then use those abstractions for planning. But the deeper idea is older and cleaner:</p>

<blockquote>
  <p>intelligence can be framed as settling into states that make the world internally consistent.</p>
</blockquote>

<p>That is the energy-based thread. Hopfield networks gave it a physical metaphor. Boltzmann machines made it probabilistic and learnable. LeCun’s energy-based models generalized it into a modeling principle. JEPA is one modern answer to the question that fell out of that lineage: what should the model assign low energy to?</p>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Hopfield</strong></td>
        <td><a href="https://authors.library.caltech.edu/records/w41x7-8bn13">Neural networks and physical systems with emergent collective computational abilities</a></td>
      </tr>
      <tr>
        <td><strong>Boltzmann machine</strong></td>
        <td><a href="https://doi.org/10.1207/s15516709cog0901_7">A Learning Algorithm for Boltzmann Machines</a></td>
      </tr>
      <tr>
        <td><strong>Energy-based learning</strong></td>
        <td><a href="https://yann.lecun.org/exdb/publis/pdf/lecun-06.pdf">A Tutorial on Energy-Based Learning</a></td>
      </tr>
      <tr>
        <td><strong>JEPA position paper</strong></td>
        <td><a href="https://openreview.net/pdf/315d43ba26f55357a84cec9a7ed15a6610094f79.pdf">A Path Towards Autonomous Machine Intelligence</a></td>
      </tr>
      <tr>
        <td><strong>I-JEPA</strong></td>
        <td><a href="https://arxiv.org/abs/2301.08243">Self-Supervised Learning from Images with a Joint-Embedding Predictive Architecture</a></td>
      </tr>
      <tr>
        <td><strong>V-JEPA</strong></td>
        <td><a href="https://arxiv.org/abs/2404.08471">Revisiting Feature Prediction for Learning Visual Representations from Video</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="hopfield-memory-as-a-valley">Hopfield: Memory as a Valley</h2>

<p>Hopfield’s 1982 paper is usually introduced as associative memory. Store a set of patterns. Give the network a noisy or partial pattern. Let the recurrent dynamics run. If the stored pattern is strong enough and the starting point is close enough, the system settles into the nearest remembered pattern.</p>

<p>The important conceptual move is that recall is not a lookup table. It is motion downhill.</p>

<p>Each network state has an energy. Stable memories are low-energy basins. The update rule decreases energy until the system reaches an attractor. That gives you a physical picture of computation: a memory is not merely an addressable record; it is a basin in a landscape. Recognition is the act of falling into the right basin.</p>

<p>That picture matters because it joins three ideas that still show up in modern representation learning:</p>

<ul>
  <li><strong>Representation</strong>: a pattern is encoded as a state of many simple units.</li>
  <li><strong>Inference</strong>: computation is the process of finding a compatible low-energy state.</li>
  <li><strong>Robustness</strong>: damaged or partial input can still land in the same attractor.</li>
</ul>

<p>Hopfield networks are limited, but the metaphor is durable. A model can know something by making the correct configuration easier to settle into than the incorrect ones.</p>

<h2 id="boltzmann-search-the-landscape-learn-the-landscape">Boltzmann: Search the Landscape, Learn the Landscape</h2>

<p>The Boltzmann machine keeps the energy landscape but adds stochasticity. Instead of deterministically falling into the nearest basin, units update probabilistically, with low-energy states more likely than high-energy states. Temperature controls how much the system explores.</p>

<p>That one change makes the architecture feel less like a fixed memory and more like a generative model. The machine can sample states. It can represent uncertainty. Most importantly, it has a learning story: adjust weights so observed data configurations become lower energy than configurations the model dreams up on its own.</p>

<p>The core contrast is:</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Low-energy states mean</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Hopfield network</td>
      <td>Stored memories / attractors</td>
    </tr>
    <tr>
      <td>Boltzmann machine</td>
      <td>Likely configurations under the learned distribution</td>
    </tr>
    <tr>
      <td>Energy-based model</td>
      <td>Compatible pairs, structures, or decisions</td>
    </tr>
  </tbody>
</table>

<p>This is the first bridge toward the modern language. A good model is not merely a function that maps input to output. It is a system that scores configurations. Learning reshapes the score surface so correct configurations become cheap and incorrect ones become expensive.</p>

<h2 id="energy-based-models-a-general-scoring-rule">Energy-Based Models: A General Scoring Rule</h2>

<p>LeCun’s energy-based learning tutorial generalizes the pattern:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>E(x, y)
</code></pre></div></div>

<p>The model assigns a scalar energy to a proposed pair. If <code class="language-plaintext highlighter-rouge">x</code> is an input and <code class="language-plaintext highlighter-rouge">y</code> is a candidate answer, the model should give low energy to compatible pairs and high energy to incompatible pairs. Prediction becomes optimization:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>y* = argmin_y E(x, y)
</code></pre></div></div>

<p>That is a broad frame. Classifiers can be read this way. Structured prediction can be read this way. Planning can be read this way. The energy function is not required to be a normalized probability distribution. That matters because normalization is often the expensive or impossible part.</p>

<p>But energy models have a practical problem: if you tell the model only to make good answers low energy, it may make everything low energy. Useful learning needs a way to avoid collapse. Classical Boltzmann machines use negative samples. Contrastive methods compare positives and negatives. Other methods use architectural constraints, regularizers, variance terms, stop-gradients, masking, or target encoders.</p>

<p>This collapse problem is one of the quiet background reasons JEPA is interesting.</p>

<h2 id="jepa-low-energy-in-representation-space">JEPA: Low Energy in Representation Space</h2>

<p>JEPA moves the prediction target out of raw observation space.</p>

<p>Instead of asking a model to reconstruct every missing pixel or token, it asks the model to predict the representation of hidden or future content from the representation of visible context. In I-JEPA, a context block from an image predicts the embeddings of target blocks. In V-JEPA, video context predicts video features. The prediction is not “what exact pixels were missing?” but “what abstract state should be true there?”</p>

<p>That changes the energy question:</p>

<table>
  <thead>
    <tr>
      <th>Generative reconstruction</th>
      <th>JEPA-style prediction</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Match raw pixels/tokens</td>
      <td>Match latent representations</td>
    </tr>
    <tr>
      <td>Spend capacity on high-frequency detail</td>
      <td>Spend capacity on semantic structure</td>
    </tr>
    <tr>
      <td>Model every unpredictable nuisance</td>
      <td>Discard what is not useful or predictable</td>
    </tr>
    <tr>
      <td>Often likelihood-like</td>
      <td>Energy / compatibility-like</td>
    </tr>
  </tbody>
</table>

<p>This is the old energy idea in a new location. The low-energy state is no longer a binary memory pattern or a sampled visible/hidden configuration. It is a compatible relationship between context representation, target representation, and sometimes an action or latent variable.</p>

<p>For world models, that is the attraction: the model does not have to generate the whole future frame. It needs to represent the aspects of the future that matter for understanding and control. The “energy” is the mismatch between predicted latent state and target latent state.</p>

<h2 id="the-lineage">The Lineage</h2>

<p>The through-line is not that Hopfield networks literally became JEPA. The architectures are different. The training machinery is different. The scale is different.</p>

<p>The through-line is the habit of thought:</p>

<ol>
  <li>Treat cognition as finding compatible configurations.</li>
  <li>Give configurations a scalar score.</li>
  <li>Make good configurations low energy.</li>
  <li>Use dynamics, sampling, gradient descent, or a learned predictor to reach those low-energy states.</li>
  <li>Move the space of optimization upward, from raw bits to useful representations.</li>
</ol>

<p>Hopfield shows that memory can be a basin. Boltzmann machines show that probabilistic learning can reshape those basins. Energy-based learning abstracts the basin into a scoring function. JEPA asks the model to build basins in latent space, where the predictable structure of the world lives more cleanly than in pixels.</p>]]></content><author><name>Software Wrighter</name></author><category term="machine-learning" /><category term="research" /><category term="self-supervised-learning" /><category term="jepa" /><category term="energy-based-models" /><category term="hopfield-networks" /><category term="boltzmann-machines" /><category term="self-supervised-learning" /><category term="world-models" /><category term="yann-lecun" /><summary type="html"><![CDATA[JEPA can sound like a sudden new architecture: predict hidden pieces of the world in representation space, avoid pixel reconstruction, learn useful abstractions, then use those abstractions for planning. But the deeper idea is older and cleaner: intelligence can be framed as settling into states that make the world internally consistent. That is the energy-based thread. Hopfield networks gave it a physical metaphor. Boltzmann machines made it probabilistic and learnable. LeCun’s energy-based models generalized it into a modeling principle. JEPA is one modern answer to the question that fell out of that lineage: what should the model assign low energy to? Resource Link Hopfield Neural networks and physical systems with emergent collective computational abilities Boltzmann machine A Learning Algorithm for Boltzmann Machines Energy-based learning A Tutorial on Energy-Based Learning JEPA position paper A Path Towards Autonomous Machine Intelligence I-JEPA Self-Supervised Learning from Images with a Joint-Embedding Predictive Architecture V-JEPA Revisiting Feature Prediction for Learning Visual Representations from Video Hopfield: Memory as a Valley Hopfield’s 1982 paper is usually introduced as associative memory. Store a set of patterns. Give the network a noisy or partial pattern. Let the recurrent dynamics run. If the stored pattern is strong enough and the starting point is close enough, the system settles into the nearest remembered pattern. The important conceptual move is that recall is not a lookup table. It is motion downhill. Each network state has an energy. Stable memories are low-energy basins. The update rule decreases energy until the system reaches an attractor. That gives you a physical picture of computation: a memory is not merely an addressable record; it is a basin in a landscape. Recognition is the act of falling into the right basin. That picture matters because it joins three ideas that still show up in modern representation learning: Representation: a pattern is encoded as a state of many simple units. Inference: computation is the process of finding a compatible low-energy state. Robustness: damaged or partial input can still land in the same attractor. Hopfield networks are limited, but the metaphor is durable. A model can know something by making the correct configuration easier to settle into than the incorrect ones. Boltzmann: Search the Landscape, Learn the Landscape The Boltzmann machine keeps the energy landscape but adds stochasticity. Instead of deterministically falling into the nearest basin, units update probabilistically, with low-energy states more likely than high-energy states. Temperature controls how much the system explores. That one change makes the architecture feel less like a fixed memory and more like a generative model. The machine can sample states. It can represent uncertainty. Most importantly, it has a learning story: adjust weights so observed data configurations become lower energy than configurations the model dreams up on its own. The core contrast is: Model Low-energy states mean Hopfield network Stored memories / attractors Boltzmann machine Likely configurations under the learned distribution Energy-based model Compatible pairs, structures, or decisions This is the first bridge toward the modern language. A good model is not merely a function that maps input to output. It is a system that scores configurations. Learning reshapes the score surface so correct configurations become cheap and incorrect ones become expensive. Energy-Based Models: A General Scoring Rule LeCun’s energy-based learning tutorial generalizes the pattern: E(x, y) The model assigns a scalar energy to a proposed pair. If x is an input and y is a candidate answer, the model should give low energy to compatible pairs and high energy to incompatible pairs. Prediction becomes optimization: y* = argmin_y E(x, y) That is a broad frame. Classifiers can be read this way. Structured prediction can be read this way. Planning can be read this way. The energy function is not required to be a normalized probability distribution. That matters because normalization is often the expensive or impossible part. But energy models have a practical problem: if you tell the model only to make good answers low energy, it may make everything low energy. Useful learning needs a way to avoid collapse. Classical Boltzmann machines use negative samples. Contrastive methods compare positives and negatives. Other methods use architectural constraints, regularizers, variance terms, stop-gradients, masking, or target encoders. This collapse problem is one of the quiet background reasons JEPA is interesting. JEPA: Low Energy in Representation Space JEPA moves the prediction target out of raw observation space. Instead of asking a model to reconstruct every missing pixel or token, it asks the model to predict the representation of hidden or future content from the representation of visible context. In I-JEPA, a context block from an image predicts the embeddings of target blocks. In V-JEPA, video context predicts video features. The prediction is not “what exact pixels were missing?” but “what abstract state should be true there?” That changes the energy question: Generative reconstruction JEPA-style prediction Match raw pixels/tokens Match latent representations Spend capacity on high-frequency detail Spend capacity on semantic structure Model every unpredictable nuisance Discard what is not useful or predictable Often likelihood-like Energy / compatibility-like This is the old energy idea in a new location. The low-energy state is no longer a binary memory pattern or a sampled visible/hidden configuration. It is a compatible relationship between context representation, target representation, and sometimes an action or latent variable. For world models, that is the attraction: the model does not have to generate the whole future frame. It needs to represent the aspects of the future that matter for understanding and control. The “energy” is the mismatch between predicted latent state and target latent state. The Lineage The through-line is not that Hopfield networks literally became JEPA. The architectures are different. The training machinery is different. The scale is different. The through-line is the habit of thought: Treat cognition as finding compatible configurations. Give configurations a scalar score. Make good configurations low energy. Use dynamics, sampling, gradient descent, or a learned predictor to reach those low-energy states. Move the space of optimization upward, from raw bits to useful representations. Hopfield shows that memory can be a basin. Boltzmann machines show that probabilistic learning can reshape those basins. Energy-based learning abstracts the basin into a scoring function. JEPA asks the model to build basins in latent space, where the predictable structure of the world lives more cleanly than in pixels.]]></summary></entry><entry><title type="html">AI Tools #5: nono — Sandboxing Pi Without Breaking the Loop</title><link href="https://blog.softwarewrighter.com/2026/05/16/nono-sandbox-ai-agents/" rel="alternate" type="text/html" title="AI Tools #5: nono — Sandboxing Pi Without Breaking the Loop" /><published>2026-05-16T00:15:00-07:00</published><updated>2026-05-16T00:15:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/05/16/nono-sandbox-ai-agents</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/05/16/nono-sandbox-ai-agents/"><![CDATA[<p><img src="/assets/images/posts/nature-mural.webp" class="post-marker" alt="" /></p>

<p>The promise of nono is simple: give an AI coding agent a real sandbox. Not a prompt-level warning. Not a policy reminder. A kernel-enforced boundary around what the process can read, write, delete, and contact.</p>

<p>That is exactly the kind of tool I want in the local-agent workflow from the Pi post. I am running agents on Lucy, my local AI cluster, through <code class="language-plaintext highlighter-rouge">mosh</code>, <code class="language-plaintext highlighter-rouge">tmux</code>, unprivileged user accounts, and local models. If those agents are going to edit code and run commands, they need boundaries that do not depend on the model being obedient.</p>

<p>But the path to a usable setup was not “install nono, run Pi, done.” It took a while to find a working combination of nono, Pi, Ollama, and an LLM that could do useful work without getting wedged.</p>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>nono website</strong></td>
        <td><a href="https://nono.sh/">nono.sh</a></td>
      </tr>
      <tr>
        <td><strong>nono code</strong></td>
        <td><a href="https://github.com/lukehinds/nono">lukehinds/nono</a></td>
      </tr>
      <tr>
        <td><strong>nono docs</strong></td>
        <td><a href="https://docs.nono.sh">docs.nono.sh</a></td>
      </tr>
      <tr>
        <td><strong>Pi repo</strong></td>
        <td><a href="https://github.com/badlogic/pi-mono">badlogic/pi-mono</a></td>
      </tr>
      <tr>
        <td><strong>Lucy short</strong></td>
        <td><a href="https://www.youtube.com/watch?v=wJvmBYTge7U">YouTube</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="the-goal">The Goal</h2>

<p>The goal was not theoretical sandbox purity. It was more practical:</p>

<ol>
  <li>run Pi inside a constrained environment,</li>
  <li>let Pi use Ollama for a local model,</li>
  <li>allow enough filesystem access for useful development,</li>
  <li>prevent obvious damage or secret exposure,</li>
  <li>keep the loop small enough that failures are understandable.</li>
</ol>

<p>That last point matters. Sandboxing an agent is not useful if the agent becomes too constrained to act, too confused to use its tools, or too wrapped in indirection to debug.</p>

<h2 id="the-iteration-tax">The Iteration Tax</h2>

<p>The first cost was permission tuning.</p>

<p>nono is a capability boundary. That is the point. But agent work is full of little side effects: reading project files, writing scratch files, running commands, following symlinks, touching caches, calling helper binaries, connecting to a local model server, and sometimes discovering that the next thing it needs is outside the allowlist.</p>

<p>That creates a tuning loop:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>run agent
watch it fail
inspect what was denied
adjust permissions
run again
</code></pre></div></div>

<div class="resource-box" style="float: left; margin: 0 1.2em 0.8em 0; max-width: 390px;">

  <h3 id="motivation">Motivation</h3>

  <p>Recently I have had several experiences where a model — Claude, Gemma4 — unilaterally decided to remove a file or directory it did not understand.</p>

  <p>That is astonishingly cavalier when the target was recently created and not yet tracked by git. There is no easy reflog recovery for a directory that never made it into the repository.</p>

  <p>The contrast is strange: Claude constantly asks permission for simple, undoable things, but a model can still destroy local work if the command gets through. That starts to feel like security theater: prompts that throttle usage by causing more round trips, not structural safety.</p>

  <p>I considered putting an rm wrapper earlier in PATH that just says no. But what stops a model from running /usr/bin/rm directly?</p>

  <p><strong>nono does.</strong></p>

</div>

<p>Some failures are good. They prove the sandbox is doing its job. Other failures are friction: the agent cannot reach the local service it needs, cannot write where the tool expects, or gets confused by an environment that is almost but not quite normal.</p>

<p>This is where nono gets real. The hard part is not believing in sandboxing. The hard part is finding the permission set that is narrow enough to matter and wide enough to work.</p>

<h2 id="when-models-talk-instead-of-act">When Models Talk Instead of Act</h2>

<p>The second cost was model behavior.</p>

<p>I repeatedly hit a local-model failure mode where the model would describe what it was going to do instead of actually doing it. It would outline a plan, explain the next command, or narrate the intended edit, but not drive the tool loop forward, even after repeated cajoling to just do it.</p>

<p>That is a different problem from sandboxing. nono can enforce filesystem and process boundaries, but it cannot make a weak model become an effective coding agent. If the model does not reliably convert intent into tool calls, the safest sandbox in the world just protects a process that is not doing much.</p>

<p>That distinction is important:</p>

<table>
  <thead>
    <tr>
      <th>Failure</th>
      <th>Layer</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Cannot read/write needed path</td>
      <td>Sandbox permissions</td>
    </tr>
    <tr>
      <td>Cannot reach Ollama</td>
      <td>Process/network/environment shape</td>
    </tr>
    <tr>
      <td>Describes the plan but does not act</td>
      <td>Model/tool-use behavior</td>
    </tr>
    <tr>
      <td>Makes bad edits</td>
      <td>Model capability or task fit</td>
    </tr>
  </tbody>
</table>

<p>The debugging loop has to identify which layer is failing. Otherwise every problem looks like a nono problem.</p>

<h2 id="the-wrapper-that-did-not-work">The Wrapper That Did Not Work</h2>

<p>Along the way, an AI suggested a clever-looking approach: use nono to run a Pi-aware Ollama command.</p>

<p>That sounded plausible. Put the model invocation itself inside the sandbox-aware command path. Make the pieces explicitly aware of each other. More integration should mean more control, right?</p>

<p>In practice, that seemed to cause problems. The extra wrapping made it harder to reason about who was responsible for what. Was nono constraining Pi? Was it constraining Ollama? Was Pi talking to the model server in the expected way? Was the model command itself now part of the agent’s tool surface?</p>

<p>The better shape was simpler:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nono runs Pi
Pi calls Ollama
Ollama serves the model
</code></pre></div></div>

<p>That preserves the boundary where I actually wanted it: around the agent process and its filesystem behavior. Ollama remains the model service. Pi remains the agent loop. nono remains the sandbox.</p>

<h2 id="the-usable-shape">The Usable Shape</h2>

<p>The most usable setup so far is:</p>

<ul>
  <li>run Pi under nono,</li>
  <li>let Pi call Ollama normally,</li>
  <li>use <code class="language-plaintext highlighter-rouge">gemma4</code>,</li>
  <li>keep the permissions narrow but not theatrical,</li>
  <li>iterate on the deny/fail cases until the agent can actually work.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">gemma4</code> worked better for me than the Qwen and Mistral models I tried in this workflow. That is not a universal benchmark result. It is a practical observation from this setup: nono plus Pi plus Ollama needs a model that can keep the tool loop moving.</p>

<p>This also changes what “model evaluation” means. I do not only care whether a model can answer coding questions. I care whether it can participate in a constrained edit/run/debug loop:</p>

<ul>
  <li>does it use tools instead of only describing tools?</li>
  <li>does it recover from denied access?</li>
  <li>does it ask for narrower permission changes or thrash?</li>
  <li>does it keep edits small enough to inspect?</li>
  <li>does it learn from command output within the session?</li>
</ul>

<p>Those are agent-behavior questions, not just language-model questions.</p>

<h2 id="the-model-search-is-part-of-the-work">The Model Search Is Part of the Work</h2>

<p>I probably need to try many models before finding the right local-agent set.</p>

<p>There are two different targets:</p>

<ol>
  <li>models that perform useful tasks out of the box,</li>
  <li>models that are small enough, regular enough, and steerable enough to fine-tune.</li>
</ol>

<p>The first target is about immediate productivity. The second is about Lucy’s longer-term role: local models that get better at my repos, my tools, and my recurring failure modes over time.</p>

<p>That may point toward smaller models, not because smaller is automatically better, but because smaller models are more practical to iterate on locally. A model that is slightly weaker out of the box but easier to fine-tune may be more valuable than a stronger local model that is too expensive to adapt.</p>

<h2 id="small-models-more-attempts">Small Models, More Attempts</h2>

<p>There is also an inference-time angle.</p>

<p>Some problems do not require one perfect answer from one large model. They can be attacked by repeated attempts from a smaller model, especially when there is a verifier: tests, type checks, linters, golden outputs, or a human reviewing a small diff.</p>

<p>That is the same broad lesson as the repeated-sampling work I wrote about in <a href="/2026/04/24/large-language-monkeys-scaling-inference/">Large-Language-Monkeys</a>: a smaller model plus multiple attempts plus a verifier can sometimes match or beat a larger one-shot model.</p>

<p>For local agents, the tradeoff becomes concrete:</p>

<table>
  <thead>
    <tr>
      <th>Approach</th>
      <th>Likely tradeoff</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Large model, one attempt</td>
      <td>faster wall-clock, higher per-call cost</td>
    </tr>
    <tr>
      <td>Small model, many attempts</td>
      <td>slower wall-clock, possibly lower energy/cost</td>
    </tr>
    <tr>
      <td>Small model, fine-tuned over time</td>
      <td>upfront training work, better local fit</td>
    </tr>
  </tbody>
</table>

<p>The energy question is not automatic. A small model looping badly can waste time and power. But a small model that makes cheap attempts against a good verifier may be the better local computation shape.</p>

<p>That is why nono matters here. If I am going to let smaller local models iterate, fail, and try again, I want the iteration loop to happen inside a boundary.</p>

<h2 id="what-nono-is-really-buying">What nono Is Really Buying</h2>

<p>nono is not making the model smarter. It is making the experiment safer.</p>

<p>That safety changes what I am willing to try. I can give an agent a real shell and a real project while still narrowing the blast radius. I can test local models that may be clumsy. I can preserve transcripts and failures for later training. I can let the loop run longer without treating every mistake as a potential catastrophe.</p>

<p>That is the practical value: sandboxing turns local-agent experimentation from reckless into routine.</p>

<h2 id="key-takeaways">Key Takeaways</h2>

<ol>
  <li>
    <p><strong>Sandboxing is an integration problem, not just a security checkbox.</strong> The permissions have to match the agent’s real workflow.</p>
  </li>
  <li>
    <p><strong>The cleanest setup was layered, not clever.</strong> nono runs Pi; Pi calls Ollama; Ollama serves the model.</p>
  </li>
  <li>
    <p><strong>Model behavior dominates quickly.</strong> Some models plan instead of act, and sandboxing cannot fix that.</p>
  </li>
  <li>
    <p><strong>The account boundary matters too.</strong> I am combining unprivileged Linux accounts, one agent per repo, with nono so the system prevents actual errors my LLMs repeatedly make: no more erasing files without recourse, no more multiple agents modifying the same repo without coordination, and push access only from a coordinator-agent account.</p>
  </li>
  <li>
    <p><strong>Gemma4 was the most usable of the models I tried in this loop.</strong> Qwen and Mistral were less effective in this particular setup.</p>
  </li>
  <li>
    <p><strong>The long game is local learning.</strong> Sandboxed, observable agent runs can become the raw material for fine-tuning models that learn from repeated mistakes.</p>
  </li>
</ol>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://nono.sh/">nono Website</a></li>
  <li><a href="https://github.com/lukehinds/nono">nono GitHub Repository</a></li>
  <li><a href="https://docs.nono.sh">nono Documentation</a></li>
  <li><a href="https://github.com/badlogic/pi-mono">Pi Mono Repository</a></li>
</ul>]]></content><author><name>Software Wrighter</name></author><category term="security" /><category term="agents" /><category term="tools" /><category term="nono" /><category term="pi" /><category term="ollama" /><category term="gemma" /><category term="qwen" /><category term="mistral" /><category term="sandboxing" /><category term="landlock" /><category term="seatbelt" /><category term="ai-agents" /><category term="local-llm" /><summary type="html"><![CDATA[The promise of nono is simple: give an AI coding agent a real sandbox. Not a prompt-level warning. Not a policy reminder. A kernel-enforced boundary around what the process can read, write, delete, and contact. That is exactly the kind of tool I want in the local-agent workflow from the Pi post. I am running agents on Lucy, my local AI cluster, through mosh, tmux, unprivileged user accounts, and local models. If those agents are going to edit code and run commands, they need boundaries that do not depend on the model being obedient. But the path to a usable setup was not “install nono, run Pi, done.” It took a while to find a working combination of nono, Pi, Ollama, and an LLM that could do useful work without getting wedged. Resource Link nono website nono.sh nono code lukehinds/nono nono docs docs.nono.sh Pi repo badlogic/pi-mono Lucy short YouTube Comments Discord The Goal The goal was not theoretical sandbox purity. It was more practical: run Pi inside a constrained environment, let Pi use Ollama for a local model, allow enough filesystem access for useful development, prevent obvious damage or secret exposure, keep the loop small enough that failures are understandable. That last point matters. Sandboxing an agent is not useful if the agent becomes too constrained to act, too confused to use its tools, or too wrapped in indirection to debug. The Iteration Tax The first cost was permission tuning. nono is a capability boundary. That is the point. But agent work is full of little side effects: reading project files, writing scratch files, running commands, following symlinks, touching caches, calling helper binaries, connecting to a local model server, and sometimes discovering that the next thing it needs is outside the allowlist. That creates a tuning loop: run agent watch it fail inspect what was denied adjust permissions run again Motivation Recently I have had several experiences where a model — Claude, Gemma4 — unilaterally decided to remove a file or directory it did not understand. That is astonishingly cavalier when the target was recently created and not yet tracked by git. There is no easy reflog recovery for a directory that never made it into the repository. The contrast is strange: Claude constantly asks permission for simple, undoable things, but a model can still destroy local work if the command gets through. That starts to feel like security theater: prompts that throttle usage by causing more round trips, not structural safety. I considered putting an rm wrapper earlier in PATH that just says no. But what stops a model from running /usr/bin/rm directly? nono does. Some failures are good. They prove the sandbox is doing its job. Other failures are friction: the agent cannot reach the local service it needs, cannot write where the tool expects, or gets confused by an environment that is almost but not quite normal. This is where nono gets real. The hard part is not believing in sandboxing. The hard part is finding the permission set that is narrow enough to matter and wide enough to work. When Models Talk Instead of Act The second cost was model behavior. I repeatedly hit a local-model failure mode where the model would describe what it was going to do instead of actually doing it. It would outline a plan, explain the next command, or narrate the intended edit, but not drive the tool loop forward, even after repeated cajoling to just do it. That is a different problem from sandboxing. nono can enforce filesystem and process boundaries, but it cannot make a weak model become an effective coding agent. If the model does not reliably convert intent into tool calls, the safest sandbox in the world just protects a process that is not doing much. That distinction is important: Failure Layer Cannot read/write needed path Sandbox permissions Cannot reach Ollama Process/network/environment shape Describes the plan but does not act Model/tool-use behavior Makes bad edits Model capability or task fit The debugging loop has to identify which layer is failing. Otherwise every problem looks like a nono problem. The Wrapper That Did Not Work Along the way, an AI suggested a clever-looking approach: use nono to run a Pi-aware Ollama command. That sounded plausible. Put the model invocation itself inside the sandbox-aware command path. Make the pieces explicitly aware of each other. More integration should mean more control, right? In practice, that seemed to cause problems. The extra wrapping made it harder to reason about who was responsible for what. Was nono constraining Pi? Was it constraining Ollama? Was Pi talking to the model server in the expected way? Was the model command itself now part of the agent’s tool surface? The better shape was simpler: nono runs Pi Pi calls Ollama Ollama serves the model That preserves the boundary where I actually wanted it: around the agent process and its filesystem behavior. Ollama remains the model service. Pi remains the agent loop. nono remains the sandbox. The Usable Shape The most usable setup so far is: run Pi under nono, let Pi call Ollama normally, use gemma4, keep the permissions narrow but not theatrical, iterate on the deny/fail cases until the agent can actually work. gemma4 worked better for me than the Qwen and Mistral models I tried in this workflow. That is not a universal benchmark result. It is a practical observation from this setup: nono plus Pi plus Ollama needs a model that can keep the tool loop moving. This also changes what “model evaluation” means. I do not only care whether a model can answer coding questions. I care whether it can participate in a constrained edit/run/debug loop: does it use tools instead of only describing tools? does it recover from denied access? does it ask for narrower permission changes or thrash? does it keep edits small enough to inspect? does it learn from command output within the session? Those are agent-behavior questions, not just language-model questions. The Model Search Is Part of the Work I probably need to try many models before finding the right local-agent set. There are two different targets: models that perform useful tasks out of the box, models that are small enough, regular enough, and steerable enough to fine-tune. The first target is about immediate productivity. The second is about Lucy’s longer-term role: local models that get better at my repos, my tools, and my recurring failure modes over time. That may point toward smaller models, not because smaller is automatically better, but because smaller models are more practical to iterate on locally. A model that is slightly weaker out of the box but easier to fine-tune may be more valuable than a stronger local model that is too expensive to adapt. Small Models, More Attempts There is also an inference-time angle. Some problems do not require one perfect answer from one large model. They can be attacked by repeated attempts from a smaller model, especially when there is a verifier: tests, type checks, linters, golden outputs, or a human reviewing a small diff. That is the same broad lesson as the repeated-sampling work I wrote about in Large-Language-Monkeys: a smaller model plus multiple attempts plus a verifier can sometimes match or beat a larger one-shot model. For local agents, the tradeoff becomes concrete: Approach Likely tradeoff Large model, one attempt faster wall-clock, higher per-call cost Small model, many attempts slower wall-clock, possibly lower energy/cost Small model, fine-tuned over time upfront training work, better local fit The energy question is not automatic. A small model looping badly can waste time and power. But a small model that makes cheap attempts against a good verifier may be the better local computation shape. That is why nono matters here. If I am going to let smaller local models iterate, fail, and try again, I want the iteration loop to happen inside a boundary. What nono Is Really Buying nono is not making the model smarter. It is making the experiment safer. That safety changes what I am willing to try. I can give an agent a real shell and a real project while still narrowing the blast radius. I can test local models that may be clumsy. I can preserve transcripts and failures for later training. I can let the loop run longer without treating every mistake as a potential catastrophe. That is the practical value: sandboxing turns local-agent experimentation from reckless into routine. Key Takeaways Sandboxing is an integration problem, not just a security checkbox. The permissions have to match the agent’s real workflow. The cleanest setup was layered, not clever. nono runs Pi; Pi calls Ollama; Ollama serves the model. Model behavior dominates quickly. Some models plan instead of act, and sandboxing cannot fix that. The account boundary matters too. I am combining unprivileged Linux accounts, one agent per repo, with nono so the system prevents actual errors my LLMs repeatedly make: no more erasing files without recourse, no more multiple agents modifying the same repo without coordination, and push access only from a coordinator-agent account. Gemma4 was the most usable of the models I tried in this loop. Qwen and Mistral were less effective in this particular setup. The long game is local learning. Sandboxed, observable agent runs can become the raw material for fine-tuning models that learn from repeated mistakes. Resources nono Website nono GitHub Repository nono Documentation Pi Mono Repository]]></summary></entry><entry><title type="html">AI Tools #4: Pi — The Minimal Agent That Stays Out of the Way</title><link href="https://blog.softwarewrighter.com/2026/05/16/pi-minimal-agent/" rel="alternate" type="text/html" title="AI Tools #4: Pi — The Minimal Agent That Stays Out of the Way" /><published>2026-05-16T00:15:00-07:00</published><updated>2026-05-16T00:15:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/05/16/pi-minimal-agent</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/05/16/pi-minimal-agent/"><![CDATA[<p><img src="/assets/images/posts/flowers.webp" class="post-marker" alt="" /></p>

<p>Four tools. Read, Write, Edit, Bash.</p>

<p>That is the part of Pi that looks like a slogan, but after using it the point feels less like minimalism for its own sake and more like friction control. Pi is not trying to become the center of the development environment. It is a small agent loop that can read files, change files, run commands, and leave the rest of the system alone.</p>

<p>My setup makes that especially visible. I use <code class="language-plaintext highlighter-rouge">mosh</code> from my MacBook to connect to an Arch Linux server, because it survives network hiccups better than plain <code class="language-plaintext highlighter-rouge">ssh</code>. On that server I log into an unprivileged user account and run <code class="language-plaintext highlighter-rouge">tmux</code>, which gives me multiple persistent PTYs. One tmux window runs Pi with <code class="language-plaintext highlighter-rouge">gemma4</code> on an RTX 3090 with 24 GB of VRAM. Another tmux window is just a shell prompt, or sometimes an Emacs shell.</p>

<p>I use the same general pattern for other coding agents: Claude Code, Codex, Gemini, and opencode using the Z.ai dev plan with GLM-5. That makes Pi’s shape easier to compare. The machine, project, shell, and tmux workflow stay mostly constant. What changes is how much agent framework shows up before the model starts doing useful work.</p>

<p>That makes Pi a useful counterweight to the current agent-tooling habit of turning every workflow into a platform.</p>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Pi Mono Repo</strong></td>
        <td><a href="https://github.com/badlogic/pi-mono">badlogic/pi-mono</a></td>
      </tr>
      <tr>
        <td><strong>Armin’s Extensions</strong></td>
        <td><a href="https://github.com/mitsuhiko/agent-stuff">mitsuhiko/agent-stuff</a></td>
      </tr>
      <tr>
        <td><strong>Article</strong></td>
        <td><a href="https://lucumr.pocoo.org/2026/1/31/pi/">Pi: The Minimal Agent</a></td>
      </tr>
      <tr>
        <td><strong>Lucy short</strong></td>
        <td><a href="https://www.youtube.com/watch?v=wJvmBYTge7U">YouTube</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="the-working-shape">The Working Shape</h2>

<p>My current Pi setup is not a cloud-agent command center. It is a local model, a default thinking level, and one package:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"defaultModel"</span><span class="p">:</span><span class="w"> </span><span class="s2">"gemma4"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"defaultProvider"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ollama"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"lastChangelogVersion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0.74.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"packages"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"npm:@ollama/pi-web-search"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"defaultThinkingLevel"</span><span class="p">:</span><span class="w"> </span><span class="s2">"medium"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>That lives at:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.pi/agent/settings.json
</code></pre></div></div>

<div class="resource-box" style="float: left; margin: 0 1.2em 0.8em 0; max-width: 390px;">

  <h3 id="motivation">Motivation</h3>

  <p>The practical motivation is Lucy, my local AI cluster (<a href="https://www.youtube.com/watch?v=wJvmBYTge7U">short video</a>). I want local LLMs to take on simpler development tasks without every small question or edit going out to a frontier model.</p>

  <p>That does not mean pretending a local model is equivalent to Claude, Codex, Gemini, or GLM-5 on every task. It means finding the band of work where locality, cost, privacy, and iteration speed matter more than maximum model strength.</p>

  <p>I have been using opencode for that local-agent lane, and Pi is another experiment in the same direction. The question is not just whether this agent can solve a task. It is whether this agent makes local-model development feel cheap enough and clear enough that I will use it repeatedly.</p>

  <p>The longer-term plan is to fine-tune local models so they get better at my specific tasks over time. I want agents that can learn from their mistakes instead of merely forgetting them after the session ends.</p>

</div>

<p>This is the interesting version of Pi to me: not “look how many integrations this has,” but “look how little standing machinery needs to be loaded before the model can start doing useful work.” A local Ollama model is enough to keep the loop close. One web-search package is enough to give it a narrow escape hatch when local context is not enough. The rest is just the agent doing agent things against the current working directory.</p>

<p>Pi matters in that context because its loop is small enough to observe. If I want to turn agent experience into future training data, I need transcripts and actions that are easy to understand. A minimal agent loop is not just easier for me to debug today; it is cleaner raw material for tomorrow’s local learning pipeline.</p>

<h2 id="minimal-does-not-mean-weak">Minimal Does Not Mean Weak</h2>

<p>The core tool set is boring:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Job</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Read</strong></td>
      <td>Inspect files</td>
    </tr>
    <tr>
      <td><strong>Write</strong></td>
      <td>Create or replace files</td>
    </tr>
    <tr>
      <td><strong>Edit</strong></td>
      <td>Patch existing files</td>
    </tr>
    <tr>
      <td><strong>Bash</strong></td>
      <td>Run commands</td>
    </tr>
  </tbody>
</table>

<p>Those four operations cover a surprising amount of software work because most coding-agent work eventually becomes:</p>

<ol>
  <li>inspect the repo,</li>
  <li>make a small change,</li>
  <li>run the command that proves or falsifies it,</li>
  <li>repeat.</li>
</ol>

<p>That is not everything an agent might do. It is, however, the irreducible loop under a lot of the tooling we dress up with dashboards, plugin catalogs, project memories, task graphs, and elaborate orchestration.</p>

<p>The point is not that Pi uses a smaller context window. Pi can use whatever context window the selected model supports. The optimization is that Pi spends less of that window on the agent framework itself. More of the model’s available attention can go to the repo, the task, the transcript, and the command output that actually matter.</p>

<h2 id="what-pi-gets-right">What Pi Gets Right</h2>

<p>Pi’s strength is that it does not make the agent feel more magical than it is. The model can read, edit, and run commands. If the result is wrong, the failure is usually visible in the transcript or the filesystem.</p>

<p>That matters. Agent systems become hard to debug when too much behavior is hidden behind framework policy: tool routers, memory layers, implicit plans, autonomous retries, invisible summarizers. Those pieces can be useful, but they also make the system harder to reason about.</p>

<p>Pi’s small surface area gives it three practical advantages:</p>

<ul>
  <li><strong>Low ceremony</strong>: starting a session does not feel like launching infrastructure.</li>
  <li><strong>Good failure shape</strong>: when it gets confused, the mistake is usually local.</li>
  <li><strong>Efficient context use</strong>: the initial context is not crowded by unused capabilities.</li>
  <li><strong>Easy composition</strong>: additional behavior can live outside the core loop.</li>
</ul>

<p>That last point is the important one. Minimal systems survive contact with real work when they have an extension path. Pi’s philosophy is not “never add capabilities.” It is “do not pre-spend context on every capability someone might want someday.”</p>

<h2 id="local-models-change-the-feel">Local Models Change the Feel</h2>

<p>Using Pi with Ollama changes the social contract of the tool. A local model is not always the smartest model in the room, but it is cheap to call, private by default, and always available when the machine is available.</p>

<p>That makes Pi useful for narrower work than I would hand to a frontier coding agent:</p>

<ul>
  <li>asking it to inspect a small code path,</li>
  <li>generating a first-pass script,</li>
  <li>trying a quick refactor in a disposable branch,</li>
  <li>keeping a local search/edit/run loop warm while I think.</li>
</ul>

<p>The settings file captures that stance. <code class="language-plaintext highlighter-rouge">gemma4</code> via <code class="language-plaintext highlighter-rouge">ollama</code>, medium thinking, one web-search package. Enough help to be useful. Not enough machinery to become a second project.</p>

<h2 id="openclaw-is-context-not-the-headline">OpenClaw Is Context, Not the Headline</h2>

<p>Pi is also part of a broader ecosystem. OpenClaw and related experiments build larger agent experiences on top of Pi-style pieces. That is worth mentioning because it proves the core can be embedded.</p>

<p>But for this post, OpenClaw is not the main point. The main point is that Pi itself is a clean reference design for a coding agent:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>model + prompt + four tools + transcript + working directory
</code></pre></div></div>

<p>Everything else should have to justify itself.</p>

<h2 id="the-slant-after-using-it">The Slant After Using It</h2>

<p>Before using Pi, the obvious story is “minimal agent has only four tools.” After using it, the better story is “minimal agents preserve mechanical sympathy by treating context as a working budget.”</p>

<p>You know what the agent can touch. You know what it can run. You know where configuration lives. You can look at the settings file and understand the operating posture in ten seconds:</p>

<ul>
  <li>local model,</li>
  <li>local provider,</li>
  <li>medium reasoning effort,</li>
  <li>one explicit package.</li>
</ul>

<p>That is a better baseline than most agent frameworks provide. A big model context window is still valuable. Pi’s advantage is that it does not fill that window with framework overhead before the problem has earned it.</p>

<h2 id="key-takeaways">Key Takeaways</h2>

<ol>
  <li>
    <p><strong>The useful unit is the loop.</strong> Read, edit, run, observe is the center of coding-agent work.</p>
  </li>
  <li>
    <p><strong>Minimal cores age well.</strong> The less policy hidden in the core, the easier the system is to debug and extend.</p>
  </li>
  <li>
    <p><strong>Local models are a different workflow, not just a cheaper backend.</strong> Pi plus Ollama makes small, frequent agent use feel natural.</p>
  </li>
  <li>
    <p><strong>Context efficiency is an optimization, not a constraint.</strong> Pi can use the model’s full context when the task calls for it; it simply starts by spending fewer tokens on itself.</p>
  </li>
  <li>
    <p><strong>Extensions should orbit the core.</strong> Packages and integrations are useful, but they should not make the agent’s basic behavior mysterious.</p>
  </li>
</ol>

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://github.com/badlogic/pi-mono">Pi Mono Repository</a></li>
  <li><a href="https://github.com/mitsuhiko/agent-stuff">Armin’s Extensions</a></li>
  <li><a href="https://lucumr.pocoo.org/2026/1/31/pi/">Pi: The Minimal Agent</a></li>
</ul>]]></content><author><name>Software Wrighter</name></author><category term="llm" /><category term="agents" /><category term="tools" /><category term="pi" /><category term="coding-agents" /><category term="minimal-agent" /><category term="local-llm" /><category term="ollama" /><category term="mario-zechner" /><category term="ai-tools" /><summary type="html"><![CDATA[Four tools. Read, Write, Edit, Bash. That is the part of Pi that looks like a slogan, but after using it the point feels less like minimalism for its own sake and more like friction control. Pi is not trying to become the center of the development environment. It is a small agent loop that can read files, change files, run commands, and leave the rest of the system alone. My setup makes that especially visible. I use mosh from my MacBook to connect to an Arch Linux server, because it survives network hiccups better than plain ssh. On that server I log into an unprivileged user account and run tmux, which gives me multiple persistent PTYs. One tmux window runs Pi with gemma4 on an RTX 3090 with 24 GB of VRAM. Another tmux window is just a shell prompt, or sometimes an Emacs shell. I use the same general pattern for other coding agents: Claude Code, Codex, Gemini, and opencode using the Z.ai dev plan with GLM-5. That makes Pi’s shape easier to compare. The machine, project, shell, and tmux workflow stay mostly constant. What changes is how much agent framework shows up before the model starts doing useful work. That makes Pi a useful counterweight to the current agent-tooling habit of turning every workflow into a platform. Resource Link Pi Mono Repo badlogic/pi-mono Armin’s Extensions mitsuhiko/agent-stuff Article Pi: The Minimal Agent Lucy short YouTube Comments Discord The Working Shape My current Pi setup is not a cloud-agent command center. It is a local model, a default thinking level, and one package: { "defaultModel": "gemma4", "defaultProvider": "ollama", "lastChangelogVersion": "0.74.0", "packages": [ "npm:@ollama/pi-web-search" ], "defaultThinkingLevel": "medium" } That lives at: ~/.pi/agent/settings.json Motivation The practical motivation is Lucy, my local AI cluster (short video). I want local LLMs to take on simpler development tasks without every small question or edit going out to a frontier model. That does not mean pretending a local model is equivalent to Claude, Codex, Gemini, or GLM-5 on every task. It means finding the band of work where locality, cost, privacy, and iteration speed matter more than maximum model strength. I have been using opencode for that local-agent lane, and Pi is another experiment in the same direction. The question is not just whether this agent can solve a task. It is whether this agent makes local-model development feel cheap enough and clear enough that I will use it repeatedly. The longer-term plan is to fine-tune local models so they get better at my specific tasks over time. I want agents that can learn from their mistakes instead of merely forgetting them after the session ends. This is the interesting version of Pi to me: not “look how many integrations this has,” but “look how little standing machinery needs to be loaded before the model can start doing useful work.” A local Ollama model is enough to keep the loop close. One web-search package is enough to give it a narrow escape hatch when local context is not enough. The rest is just the agent doing agent things against the current working directory. Pi matters in that context because its loop is small enough to observe. If I want to turn agent experience into future training data, I need transcripts and actions that are easy to understand. A minimal agent loop is not just easier for me to debug today; it is cleaner raw material for tomorrow’s local learning pipeline. Minimal Does Not Mean Weak The core tool set is boring: Tool Job Read Inspect files Write Create or replace files Edit Patch existing files Bash Run commands Those four operations cover a surprising amount of software work because most coding-agent work eventually becomes: inspect the repo, make a small change, run the command that proves or falsifies it, repeat. That is not everything an agent might do. It is, however, the irreducible loop under a lot of the tooling we dress up with dashboards, plugin catalogs, project memories, task graphs, and elaborate orchestration. The point is not that Pi uses a smaller context window. Pi can use whatever context window the selected model supports. The optimization is that Pi spends less of that window on the agent framework itself. More of the model’s available attention can go to the repo, the task, the transcript, and the command output that actually matter. What Pi Gets Right Pi’s strength is that it does not make the agent feel more magical than it is. The model can read, edit, and run commands. If the result is wrong, the failure is usually visible in the transcript or the filesystem. That matters. Agent systems become hard to debug when too much behavior is hidden behind framework policy: tool routers, memory layers, implicit plans, autonomous retries, invisible summarizers. Those pieces can be useful, but they also make the system harder to reason about. Pi’s small surface area gives it three practical advantages: Low ceremony: starting a session does not feel like launching infrastructure. Good failure shape: when it gets confused, the mistake is usually local. Efficient context use: the initial context is not crowded by unused capabilities. Easy composition: additional behavior can live outside the core loop. That last point is the important one. Minimal systems survive contact with real work when they have an extension path. Pi’s philosophy is not “never add capabilities.” It is “do not pre-spend context on every capability someone might want someday.” Local Models Change the Feel Using Pi with Ollama changes the social contract of the tool. A local model is not always the smartest model in the room, but it is cheap to call, private by default, and always available when the machine is available. That makes Pi useful for narrower work than I would hand to a frontier coding agent: asking it to inspect a small code path, generating a first-pass script, trying a quick refactor in a disposable branch, keeping a local search/edit/run loop warm while I think. The settings file captures that stance. gemma4 via ollama, medium thinking, one web-search package. Enough help to be useful. Not enough machinery to become a second project. OpenClaw Is Context, Not the Headline Pi is also part of a broader ecosystem. OpenClaw and related experiments build larger agent experiences on top of Pi-style pieces. That is worth mentioning because it proves the core can be embedded. But for this post, OpenClaw is not the main point. The main point is that Pi itself is a clean reference design for a coding agent: model + prompt + four tools + transcript + working directory Everything else should have to justify itself. The Slant After Using It Before using Pi, the obvious story is “minimal agent has only four tools.” After using it, the better story is “minimal agents preserve mechanical sympathy by treating context as a working budget.” You know what the agent can touch. You know what it can run. You know where configuration lives. You can look at the settings file and understand the operating posture in ten seconds: local model, local provider, medium reasoning effort, one explicit package. That is a better baseline than most agent frameworks provide. A big model context window is still valuable. Pi’s advantage is that it does not fill that window with framework overhead before the problem has earned it. Key Takeaways The useful unit is the loop. Read, edit, run, observe is the center of coding-agent work. Minimal cores age well. The less policy hidden in the core, the easier the system is to debug and extend. Local models are a different workflow, not just a cheaper backend. Pi plus Ollama makes small, frequent agent use feel natural. Context efficiency is an optimization, not a constraint. Pi can use the model’s full context when the task calls for it; it simply starts by spending fewer tokens on itself. Extensions should orbit the core. Packages and integrations are useful, but they should not make the agent’s basic behavior mysterious. Resources Pi Mono Repository Armin’s Extensions Pi: The Minimal Agent]]></summary></entry><entry><title type="html">Saw #9: Espanso, Kate, ShareX, and Pluggable I2C Devices on COR24</title><link href="https://blog.softwarewrighter.com/2026/05/03/saw-espanso-kate-sharex-cor24-i2c-pluggable/" rel="alternate" type="text/html" title="Saw #9: Espanso, Kate, ShareX, and Pluggable I2C Devices on COR24" /><published>2026-05-03T09:30:00-07:00</published><updated>2026-05-03T09:30:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/05/03/saw-espanso-kate-sharex-cor24-i2c-pluggable</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/05/03/saw-espanso-kate-sharex-cor24-i2c-pluggable/"><![CDATA[<p><img src="/assets/images/posts/block-eight-saws.webp" class="post-marker no-invert" alt="Eight different saws---rip, crosscut, coping, keyhole, two-handed, pruning---rendered as a woodcut" style="width: 220px;" /></p>

<div style="overflow: hidden;">

  <p>Ninth Sharpen the Saw update. <a href="/2026/04/26/saw-tuplet-smalltalk-forth-from-forth/">Last time</a> the theme was <em>forcing functions</em>: writing real programs in a new language exposes the missing language features, and outgrowing a single laptop exposes the missing project structure. This week the theme is <em>collaborating on language design</em>—the small editor-and-OS-level pieces that decide whether two people working on a new language can actually share work without friction. Plus one platform piece: turning the COR24 emulator’s I2C bus into a <em>pluggable</em> device socket so the language-side I/O examples can grow without the emulator growing.</p>

  <p>The first three tools—Espanso, Kate’s syntax-highlighting config, and the GitHub CLI standing in for ShareX—all earn their slot for the same reason: they let me share glyph input, editor support, and snippets back and forth with a colleague who’s designing <strong>PAL</strong>, an in-development language with its own non-ASCII surface syntax. The fourth thread (pluggable I2C devices on the COR24 emulator) is unrelated infrastructure for the COR24 language stack, but it’s the same shape of work: build the platform piece so the language-side experiments cost less.</p>

</div>

<!--more-->

<div class="aside-box">

  <p><strong>Why Sharpen the Saw?</strong> — The name comes from Covey’s <a href="https://en.wikipedia.org/wiki/The_7_Habits_of_Highly_Effective_People">Habit 7</a>: stop cutting long enough to sharpen the blade. This series tracks weekly investment in the tools themselves—editors, snippet expanders, screenshot pipelines, emulators, peripheral simulators—so the feature work on top goes faster.</p>

</div>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Espanso</strong></td>
        <td><a href="https://espanso.org/">espanso.org</a></td>
      </tr>
      <tr>
        <td><strong>Kate Editor</strong></td>
        <td><a href="https://kate-editor.org/">kate-editor.org</a></td>
      </tr>
      <tr>
        <td><strong>ShareX</strong></td>
        <td><a href="https://getsharex.com/">getsharex.com</a></td>
      </tr>
      <tr>
        <td><strong>COR24 Emulator</strong></td>
        <td><a href="https://github.com/sw-embed/sw-cor24-emulator">github.com/sw-embed/sw-cor24-emulator</a></td>
      </tr>
      <tr>
        <td><strong>Prior Post</strong></td>
        <td><a href="/2026/04/26/saw-tuplet-smalltalk-forth-from-forth/">Saw #8: Tuplet, Smalltalk, Forth-from-Forth, sw-MLPL Split, and I2C on COR24</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="espanso-shared-glyph-input-for-collaborating-on-a-new-language">Espanso: Shared Glyph Input for Collaborating on a New Language</h2>

<p><a href="https://espanso.org/">Espanso</a> is a cross-platform text expander written in Rust. Type a short trigger, get a replacement string anywhere your OS accepts text input—editor, terminal, browser address bar, chat window, IDE. It runs as a background service, the configuration is plain YAML in a known directory, and packages can be shared the same way other tooling shares plugins.</p>

<p>The reason Espanso earned a Sharpen the Saw slot of its own (it was already mentioned in <a href="/2026/04/26/saw-tuplet-smalltalk-forth-from-forth/">Part 8</a> as the Tuplet glyph-entry layer) is the <strong>collaboration angle</strong>: a glyph language is unusable if your collaborator can’t type the glyphs. Sharing a checked-in YAML file means both sides of the design conversation type the same triggers and produce the same source. There is no “it works on my keyboard layout” problem when the layout is a YAML file in the repo.</p>

<p>The use cases keep stacking up beyond glyphs:</p>

<ul>
  <li><strong>APL, Tuplet, and PAL glyphs</strong> — the original reason. Three Espanso config files now ship alongside the language repos: one for <a href="https://www.gnu.org/software/apl/">APL</a> glyphs (<code class="language-plaintext highlighter-rouge">⍳⍴⍵⌽</code>…), one for <a href="https://github.com/sw-vibe-coding/tuplet">Tuplet</a> (<code class="language-plaintext highlighter-rouge">▪→←ℤ⎧⎨⎩</code>…), and one for <strong>PAL</strong>, a colleague’s in-development language with its own non-ASCII surface syntax. Same trigger discipline across all three; the same muscle memory works in any editor or terminal that accepts text—and the colleague designing PAL gets a working glyph-entry layer by cloning a config, not by hand-rolling AltGr maps.</li>
  <li><strong>Date and timestamp snippets</strong> — ISO dates, blog post front-matter date strings (<code class="language-plaintext highlighter-rouge">2026-05-03 00:15:00 -0800</code>), git commit subject prefixes. Anything I type more than three times a week is a candidate.</li>
  <li><strong>Boilerplate scaffolds</strong> — front matter blocks for new posts, the standard Sharpen the Saw resource box, the standard “Why Sharpen the Saw?” aside. Every one of those used to be a copy-paste from the previous post; now they expand from a trigger.</li>
  <li><strong>Shell one-liners</strong> — the long-but-forgettable invocations: <code class="language-plaintext highlighter-rouge">git log --oneline --decorate --graph --all -n 30</code>, the <code class="language-plaintext highlighter-rouge">ffmpeg</code> call I always forget the flag order for, the <code class="language-plaintext highlighter-rouge">find . -name '*.md' -exec grep -l ...</code> pattern.</li>
</ul>

<p>The leverage is in the <em>aggregation</em>: each individual snippet saves a few seconds, but the union covers a non-trivial fraction of daily typing, and the muscle memory is the same trigger set everywhere.</p>

<p>The cost to add a new snippet is small enough (one YAML entry, no restart) that the friction curve flips: instead of “is it worth automating?”, the question becomes “is there any reason <em>not</em> to?”. The fact that adding PAL took roughly an afternoon—read the language’s glyph list, write the YAML, install on each machine—is the demonstration: the third language is dramatically cheaper than the first.</p>

<!-- TODO: link the three Espanso configs once they're published / pointable. Decide whether they live in their respective language repos or in a shared dotfiles repo. -->

<h2 id="kate-an-editor-where-a-new-language-is-a-config-file-not-a-plugin">Kate: An Editor Where a New Language Is a Config File, Not a Plugin</h2>

<p><a href="https://kate-editor.org/">Kate</a> is the KDE project’s text editor—a serious general-purpose editor that runs on Linux, macOS, and Windows. It is not a replacement for Emacs (where the language and config story is years deep) or for the IDE-of-the-week, but it earns its slot here for one specific reason: <strong>giving a collaborator’s in-development language editor support is a single XML file, not a plugin or an LSP project.</strong> That cost ratio matters a lot when the language changes weekly.</p>

<p>Beyond that, the usual fast-editor virtues:</p>

<ul>
  <li><strong>Instant startup</strong>, even on a cold launch. Useful for quick edits where a full IDE is overkill.</li>
  <li><strong>Real syntax highlighting and code folding</strong> for hundreds of languages out of the box, including the obscure ones (Forth, BASIC, Smalltalk, APL) that most “lightweight” editors treat as plain text.</li>
  <li><strong>Built-in terminal pane</strong> and a tabbed multi-document view, so it scales up from “open one file” to “open a whole directory tree” without ceremony.</li>
  <li><strong>Same UX on all three OSes</strong> — when I am bouncing between a Linux GPU box, a Mac laptop, and a Windows machine for screen-recording, having one editor with the same keybindings everywhere reduces context-switch tax.</li>
</ul>

<p>Kate’s role in the toolbox is the editor-equivalent of <code class="language-plaintext highlighter-rouge">cat</code> or <code class="language-plaintext highlighter-rouge">less</code>: not the place where the heavy work happens, but the place I open <em>first</em> when I just need to look at a file or a directory and see it formatted correctly. It has gradually displaced “open this in TextEdit / Notepad / Gedit and squint” for everything except where Emacs already wins.</p>

<p>The Sharpen the Saw value is portability: the cost of adding a new platform to the workflow drops if the editor moves with you.</p>

<h3 id="custom-syntax-highlighting-for-pal">Custom syntax highlighting for PAL</h3>

<p>Kate’s syntax highlighting is driven by <a href="https://docs.kde.org/stable5/en/kate/katepart/highlight.html">KSyntaxHighlighting XML files</a>—one file per language, declarative keyword lists, region rules, and reference styles. There’s no compiled extension, no language-server dance, no marketplace round-trip. For a language whose grammar is changing week-over-week between two collaborators, that round-trip cost is what would otherwise kill editor support entirely.</p>

<p>For <strong>PAL</strong>—a colleague’s in-development language with its own keyword set and surface syntax—the recipe is:</p>

<!-- TODO: actually walk through the steps once the PAL highlighter is working. Anchor: where does the .xml live (system path vs `~/.local/share/org.kde.syntax-highlighting/syntax/`), how to test reload, how to map file extensions, how to handle the language's specific gotchas (string escapes, comment shapes, glyphs). -->

<ol>
  <li>Drop a <code class="language-plaintext highlighter-rouge">pal.xml</code> (KSyntaxHighlighting format) into Kate’s user syntax directory.</li>
  <li>Bind the <code class="language-plaintext highlighter-rouge">.pal</code> extension and any associated glob patterns to the new highlighter.</li>
  <li>Reload (or restart Kate) and verify on a representative source file.</li>
  <li>Iterate: keyword list, comment delimiters, string and number rules, any glyph-specific tokens that overlap with the Espanso input layer.</li>
</ol>

<p>That gives PAL a real editing experience on every machine that has Kate installed—no fork, no plugin maintenance, just a single XML file to ship alongside the language. Both collaborators get the same highlighter from the same checked-in file; a grammar change is one PR away from showing up in everyone’s editor. Same pattern would work for Tuplet and for the COR24 Forth/BASIC/OCaml dialects where Kate’s stock highlighters need a few project-specific tweaks.</p>

<p>The point is the same one that drives the Espanso config story: when two people are designing a language together, <em>editor support and input methods are part of the language</em>—not a downstream nice-to-have that ships after v1.0. Cheap, version-controlled, single-file editor support changes the cadence of language design itself.</p>

<h2 id="sharex-screenshot-annotate-share">ShareX: Screenshot, Annotate, Share</h2>

<p><a href="https://getsharex.com/">ShareX</a> is a Windows screenshot-and-screen-capture tool with the entire post-capture pipeline built in: region select, scrolling capture, annotation, color picker, OCR, GIF/video record, and a configurable upload step (S3, imgur, custom HTTP, save-to-disk-with-filename-template). Open source, no account required, and the configuration is local.</p>

<p>Why it matters for a blog/video workflow:</p>

<ul>
  <li><strong>Region capture with annotation in one keystroke</strong> — highlight, arrow, blur (for sensitive screen content), text overlay. The result is ready to paste into a post or video frame without bouncing through a separate image editor.</li>
  <li><strong>Filename templates</strong> — captures land as <code class="language-plaintext highlighter-rouge">2026-05-03_kate-syntax-highlighting_01.png</code> automatically, in the right directory, ready to be moved into <code class="language-plaintext highlighter-rouge">sw-lab/assets/images/posts/</code> with no rename step.</li>
  <li><strong>GIF and short MP4 capture</strong> — for the cases where a screenshot can’t show the interaction (dropdowns, hover states, animations). The same hotkey, the same output directory, the same naming convention.</li>
  <li><strong>OCR built in</strong> — when a screenshot of code or a terminal needs to become text for inclusion in a post, no need to retype.</li>
</ul>

<p>ShareX’s value is the same as Espanso’s, in a different domain: it collapses what used to be a five-tool pipeline (capture → crop → annotate → rename → upload) into a single hotkey with sensible defaults. The blog post images for the COR24 demos and the Sharpen the Saw series go through this pipeline.</p>

<h3 id="on-mac-gh-gist-create-for-sharing-language-snippets">On Mac: <code class="language-plaintext highlighter-rouge">gh gist create</code> for sharing language snippets</h3>

<p>ShareX itself is Windows-only. The Mac-side search for an equivalent went through the usual suspects (Shottr, CleanShot, Skitch, the built-in <code class="language-plaintext highlighter-rouge">Cmd-Shift-5</code>) and ended somewhere unexpected: <strong><code class="language-plaintext highlighter-rouge">gh gist create</code></strong>, the GitHub CLI’s gist subcommand, doing the <em>share</em> half of the workflow even though it has nothing to do with screenshots.</p>

<p>The realization was that for <strong>language-design collaboration</strong>—which is what most of the day’s “I want to share this with my collaborator” moments actually are—the artifact is almost always <em>text</em>: a PAL snippet that exposes a parser ambiguity, a Tuplet expression that doesn’t lower the way I expected, a YAML excerpt from one of the Espanso configs, an error message I want a second opinion on. None of those need a screenshot. They need a URL my collaborator can open, comment on, and clone.</p>

<p><code class="language-plaintext highlighter-rouge">gh gist create</code> is exactly that:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh gist create --public --desc "PAL syntax sketch" pal-snippet.txt
gh gist create --secret config.yml          # private gist
echo "$(pbpaste)" | gh gist create --filename note.md -
</code></pre></div></div>

<p>The result is a gist URL on <code class="language-plaintext highlighter-rouge">stdout</code>, ready to paste into a chat or an email. The collaborator opens it, comments inline, or forks it with their own version of the snippet—which is a much higher-bandwidth conversation than a screenshot would be, because the <em>text is editable on the other side</em>.</p>

<p>ShareX’s “take a screenshot, get back a URL” muscle memory turned out to be the <em>wrong</em> shape for this use case. What I actually wanted was “take this code-shaped thing, get back a URL”, and <code class="language-plaintext highlighter-rouge">gh gist create</code> does that natively without going through an image at all. For the rare times an actual screenshot is needed (UI bug, terminal output that won’t paste cleanly), <code class="language-plaintext highlighter-rouge">Cmd-Shift-4</code> plus dropping the image into the relevant blog or language repo and letting <code class="language-plaintext highlighter-rouge">git push</code> carry it covers it.</p>

<!-- TODO: revisit if a real Mac-native ShareX equivalent (CleanShot X, Shottr) becomes worth the install. The gh-gist landing point is honest about what was actually missed (sharing language artifacts, not capture)---don't dress it up as a perfect substitute. -->

<p>The takeaway: when “share with my collaborator” is the actual goal, the right tool depends on the artifact’s shape. For images, ShareX’s pattern is correct. For language design, the artifact is text, and the screenshot detour is friction the gist tool removes.</p>

<h2 id="pluggable-i2c-devices-on-the-cor24-emulator">Pluggable I2C Devices on the COR24 Emulator</h2>

<p><a href="/2026/04/26/saw-tuplet-smalltalk-forth-from-forth/">Part 8</a> introduced I2C support on the <a href="https://github.com/sw-embed/sw-cor24-emulator">COR24 emulator</a>. This week’s work is the design that lets it scale: a three-layer architecture where the bus is emulated <em>once</em> at the MMIO level, and devices plug in via a trait so adding a new chip is one file plus one registry line. SPI is sketched in parallel as phase 2.</p>

<p>The motivation is the goal that justifies the bus work in the first place: <strong>adding I/O examples to the COR24 languages.</strong> BASIC, Forth, OCaml, Tuplet, and Smalltalk all benefit from the same demo library—read a temperature sensor, persist a value to an EEPROM, read the wall clock, drive a display. If the emulator hard-codes its device list, every new demo means an emulator change. If the device list is pluggable, adding a new simulated peripheral is a separate, scoped piece of work that the language demos consume as data.</p>

<h3 id="three-layers">Three layers</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Layer 1: Guest applications
  C programs + libi2c / libspi running on the COR24
  (i2cspi/tmp101 and i2cspi/tmp125 already exist)
        │  MMIO writes/reads to GPIO-style addresses
        ▼
Layer 2: Bus MMIO emulation (in CPU emulator core)
  Models the FPGA's I2C/SPI line registers exactly. Reconstructs
  logical bus events from line transitions. Routes events to
  whatever device(s) are currently attached.
        │  Bus event API: on_start / on_write_byte / on_read_byte / on_stop
        ▼
Layer 3: Pluggable virtual devices
  Implementations of an I2cSlave / SpiSlave trait for each chip
  to be modeled. New devices = new files.
</code></pre></div></div>

<p>Layers 1 and 3 grow independently—more demos, more chip models. Layer 2 is small, central, and ideally written once.</p>

<h3 id="why-this-is-harder-than-uart">Why this is harder than UART</h3>

<p>Both I2C and SPI on the COR24-TB FPGA are <strong>bit-banged GPIO</strong>, not register-driven peripherals. The CPU pokes individual line states (SCL=0, SDA=1, …) and clocks the bus itself. This is materially different from the existing UART, where the CPU writes one byte to <code class="language-plaintext highlighter-rouge">IO_UARTDATA</code> and the emulator reacts to that byte.</p>

<p>The emulator can’t just “respond when the CPU writes a byte.” It sees individual line transitions and has to <strong>reconstruct logical bus events</strong> (START, address+RW, byte-in, byte-out, ACK, STOP for I2C; bit-shift on SCLK edge for SPI) from the physical transitions, then route those events to a virtual device. Open-drain wired-AND for I2C means tracking master and slave drivers separately:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scl_line = master_scl  &amp; !slave_scl_pull
sda_line = master_sda  &amp; !slave_sda_pull
</code></pre></div></div>

<p>The bus state machine—<code class="language-plaintext highlighter-rouge">Idle → Started → RxByte → AckMasterToSlave → TxByte → AckSlaveToMaster → Stopped</code>—is driven by <code class="language-plaintext highlighter-rouge">(scl_line, sda_line)</code> <em>transitions</em>, not by writes. This is the price of MMIO-accurate emulation, but the payoff is that the same C source (<code class="language-plaintext highlighter-rouge">libi2c.c</code>, the bit-bang loop) runs unmodified on the FPGA and the emulator. If a demo ever needs different code on emulator vs. FPGA, the abstraction has leaked and the emulator should be fixed.</p>

<h3 id="the-device-trait">The device trait</h3>

<p>The pluggable extension surface is a small Rust trait:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">trait</span> <span class="n">I2cDevice</span><span class="p">:</span> <span class="nb">Send</span> <span class="p">{</span>
    <span class="k">fn</span> <span class="nf">address</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">u8</span><span class="p">;</span>
    <span class="k">fn</span> <span class="nf">name</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="o">&amp;</span><span class="nb">str</span> <span class="p">{</span> <span class="s">"i2c-device"</span> <span class="p">}</span>

    <span class="k">fn</span> <span class="nf">on_start</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>
    <span class="k">fn</span> <span class="nf">on_write_byte</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">byte</span><span class="p">:</span> <span class="nb">u8</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="n">Ack</span> <span class="p">{</span> <span class="nn">Ack</span><span class="p">::</span><span class="n">Nak</span> <span class="p">}</span>
    <span class="k">fn</span> <span class="nf">on_read_byte</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">u8</span> <span class="p">{</span> <span class="mi">0xFF</span> <span class="p">}</span>
    <span class="k">fn</span> <span class="nf">on_master_ack</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>
    <span class="k">fn</span> <span class="nf">on_master_nak</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>
    <span class="k">fn</span> <span class="nf">on_stop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>

    <span class="k">fn</span> <span class="nf">on_tick</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>                    <span class="c1">// for time-based behaviour</span>
    <span class="k">fn</span> <span class="nf">stretching_scl</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">bool</span> <span class="p">{</span> <span class="k">false</span> <span class="p">}</span>  <span class="c1">// optional clock stretching</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Adding a new chip is three things: a file at <code class="language-plaintext highlighter-rouge">src/peripherals/i2c/devices/&lt;chip&gt;.rs</code> implementing the trait, one line in <code class="language-plaintext highlighter-rouge">src/peripherals/i2c/registry.rs</code> mapping a string key to a constructor, and unit tests. No edits to the bus core, no edits to the CPU, no fork.</p>

<p>Construction is string-keyed so the CLI and config files share one parser:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>--i2c-device tmp101@0x4A
--i2c-device 'tmp101@0x4A?temp=23.5'
--i2c-device 'ds3231@0x68?epoch=2026-05-03T12:00:00Z'
--i2c-device logger@*          # passive logger on every address
--dump-i2c                     # transaction log on exit
</code></pre></div></div>

<h3 id="first-device-set">First device set</h3>

<table>
  <thead>
    <tr>
      <th>Phase</th>
      <th>Device</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">tmp101</code></td>
      <td>Validates the existing <code class="language-plaintext highlighter-rouge">tmp101.lgo</code> demo end-to-end</td>
    </tr>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">logger</code></td>
      <td>Bit-bucket device; records every event for tests + UI</td>
    </tr>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">eeprom</code></td>
      <td>Read/write semantics—exercises a different shape</td>
    </tr>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">ds3231</code></td>
      <td>RTC; multi-byte register file, common in real projects</td>
    </tr>
    <tr>
      <td>2 SPI</td>
      <td><code class="language-plaintext highlighter-rouge">tmp125</code></td>
      <td>Validates the existing <code class="language-plaintext highlighter-rouge">tmp125.lgo</code> SPI demo</td>
    </tr>
    <tr>
      <td>2 SPI</td>
      <td><code class="language-plaintext highlighter-rouge">mcp23s17</code></td>
      <td>Generic SPI GPIO expander, useful in many demos</td>
    </tr>
  </tbody>
</table>

<p>The TMP101 is the integration anchor: load the existing <code class="language-plaintext highlighter-rouge">tmp101.lgo</code> binary (already built by the FPGA-side toolchain), attach a <code class="language-plaintext highlighter-rouge">Tmp101</code> device at <code class="language-plaintext highlighter-rouge">0x4A</code>, run for N instructions, and assert the UART output matches the <code class="language-plaintext highlighter-rouge">"%.2f\n"</code> line for the configured temperature. If that single test passes, the whole stack works—libi2c bit-banging, bus state machine, device model, UART output, printf formatter.</p>

<p>The EEPROM and DS3231 are the gate before the trait is declared “public”: two devices with the same shape proves nothing; four devices with three different shapes (sensor / storage / clock / passive logger) proves the trait is general.</p>

<h3 id="spi-as-phase-2">SPI as phase 2</h3>

<p>The SPI design is the same shape, simpler. No addressing, no START/STOP, no open-drain—just a shift register clocked by SCLK while SELN is low:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">trait</span> <span class="n">SpiDevice</span><span class="p">:</span> <span class="nb">Send</span> <span class="p">{</span>
    <span class="k">fn</span> <span class="nf">name</span><span class="p">(</span><span class="o">&amp;</span><span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="o">&amp;</span><span class="nb">str</span> <span class="p">{</span> <span class="s">"spi-device"</span> <span class="p">}</span>
    <span class="k">fn</span> <span class="nf">on_select</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>
    <span class="k">fn</span> <span class="nf">on_byte</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">mosi</span><span class="p">:</span> <span class="nb">u8</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">u8</span><span class="p">;</span>  <span class="c1">// simultaneous shift</span>
    <span class="k">fn</span> <span class="nf">on_deselect</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>
    <span class="k">fn</span> <span class="nf">on_tick</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Mode 0 only (CPOL=0, CPHA=0)—which is what <code class="language-plaintext highlighter-rouge">spixchg.s</code> already implements. Once the I2C plumbing is in place, the SPI side is mostly mechanical: replace the bus state machine with the simpler shift register, re-use the device-attachment plumbing nearly verbatim.</p>

<h3 id="why-this-is-sharpen-the-saw">Why this is Sharpen the Saw</h3>

<p>Doing the bus work once at Layer 2 means <em>every COR24 language gets the same I/O library out of it</em>. The BASIC tutorial, the Forth tutorial, the OCaml tutorial, and the Tuplet tutorial all share an example set instead of each one inventing its own. The pluggable device trait means the demo library can grow without the emulator growing—same compartmentalization pattern that drove the <a href="/2026/04/26/saw-tuplet-smalltalk-forth-from-forth/#sw-mlpl-35-gb-forces-a-split-into-parallelizable-pieces">sw-MLPL split</a>, applied to peripherals.</p>

<h2 id="possible-next-steps">Possible Next Steps</h2>

<p>Not commitments—just directions any of these threads could grow if the slot opens up:</p>

<p><strong>Espanso</strong> — Bundle the per-language glyph configs (APL, Tuplet, PAL) into something installable in one step on a fresh machine, instead of three separate <code class="language-plaintext highlighter-rouge">cp</code>s. Possibly a shared dotfiles repo, possibly a per-language file that ships with each language’s repo.</p>

<p><strong>Kate</strong> — Get the PAL syntax highlighter past the “renders something” stage and confirm it survives a Kate update. A Tuplet highlighter is the obvious second one if the PAL one shakes out cleanly.</p>

<p><strong>ShareX / gh-gist</strong> — Possibly revisit a Mac-native ShareX equivalent (CleanShot X, Shottr) if the gist-as-share workflow runs out of road. For now it’s good enough that there’s no urgency.</p>

<p><strong>COR24 I2C Pluggable Devices</strong> — The plan walks an order: stub MMIO at <code class="language-plaintext highlighter-rouge">0xFF0020/0xFF0021</code>, master-line state, bus state machine, device trait, the <code class="language-plaintext highlighter-rouge">tmp101.lgo</code> end-to-end test as the gate, then <code class="language-plaintext highlighter-rouge">--i2c-device</code> CLI, a registry, a second device shape (EEPROM or DS3231) to prove the trait is general, and <code class="language-plaintext highlighter-rouge">docs/extending-i2c.md</code> before declaring the API public. SPI follows in the same shape with a simpler bus. How much of that lands and in what order is a function of how much time the I2C work earns relative to everything else competing for it.</p>

<hr />

<p><em>Glyph input, syntax highlighting, snippet sharing—three tools and one job: making a new language cheap to collaborate on. Plus a peripheral bus that does the same favor for the COR24 language stack. Follow for more Sharpen the Saw updates.</em></p>]]></content><author><name>Software Wrighter</name></author><category term="tools" /><category term="productivity" /><category term="embedded" /><category term="emulators" /><category term="languages" /><category term="language-design" /><category term="sharpen-the-saw" /><category term="espanso" /><category term="kate-editor" /><category term="sharex" /><category term="gh-cli" /><category term="gist" /><category term="language-design" /><category term="collaboration" /><category term="pal" /><category term="apl" /><category term="tuplet" /><category term="syntax-highlighting" /><category term="ksyntaxhighlighting" /><category term="cor24" /><category term="i2c" /><category term="spi" /><category term="emulator" /><category term="pluggable-devices" /><category term="tmp101" /><category term="ds3231" /><category term="eeprom" /><summary type="html"><![CDATA[Ninth Sharpen the Saw update. Last time the theme was forcing functions: writing real programs in a new language exposes the missing language features, and outgrowing a single laptop exposes the missing project structure. This week the theme is collaborating on language design—the small editor-and-OS-level pieces that decide whether two people working on a new language can actually share work without friction. Plus one platform piece: turning the COR24 emulator’s I2C bus into a pluggable device socket so the language-side I/O examples can grow without the emulator growing. The first three tools—Espanso, Kate’s syntax-highlighting config, and the GitHub CLI standing in for ShareX—all earn their slot for the same reason: they let me share glyph input, editor support, and snippets back and forth with a colleague who’s designing PAL, an in-development language with its own non-ASCII surface syntax. The fourth thread (pluggable I2C devices on the COR24 emulator) is unrelated infrastructure for the COR24 language stack, but it’s the same shape of work: build the platform piece so the language-side experiments cost less.]]></summary></entry><entry><title type="html">TBT #10: Mass Compile and PL/EDIT — 1980s Productivity Tools, Reborn on COR24 PL/SW</title><link href="https://blog.softwarewrighter.com/2026/04/30/tbt-mass-compile-pl-edit-aq-system/" rel="alternate" type="text/html" title="TBT #10: Mass Compile and PL/EDIT — 1980s Productivity Tools, Reborn on COR24 PL/SW" /><published>2026-04-30T16:00:00-07:00</published><updated>2026-04-30T16:00:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/04/30/tbt-mass-compile-pl-edit-aq-system</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/04/30/tbt-mass-compile-pl-edit-aq-system/"><![CDATA[<p><img src="/assets/images/posts/block-aq-login.webp" class="post-marker no-invert" alt="" style="width: 240px;" /></p>

<div style="overflow: hidden;">

  <p>A green screen, an ASCII-art <strong>AQ</strong> logo a foot wide, and the words <code class="language-plaintext highlighter-rouge">WELCOME TO THE AQ SYSTEM / AUTHORIZED USERS ONLY</code> over a blinking <code class="language-plaintext highlighter-rouge">LOGIN:</code> prompt. PF1=HELP, PF2=LOGON, PF3=LOGOFF, PF4=CHANGE PASSWORD, PF12=CANCEL. If you ever logged into a corporate IBM mainframe in the 1980s, you saw a screen that looked roughly like this; if you worked at IBM in those years, the screen was attached to your day for as long as a typical workday lasted. AQ — the “A queue” — was the MVS/ESA time-sharing and batch system where I worked on PL/X systems code, and the entire developer experience for hundreds of engineers ran through it.</p>

</div>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>PL/SW Live Demo</strong></td>
        <td><a href="https://sw-embed.github.io/web-sw-cor24-plsw/">sw-embed.github.io/web-sw-cor24-plsw</a></td>
      </tr>
      <tr>
        <td><strong>PL/EDIT documentation</strong></td>
        <td><a href="https://github.com/sw-embed/web-sw-cor24-plsw/blob/main/docs/pl-edit.md">docs/pl-edit.md</a></td>
      </tr>
      <tr>
        <td><strong>Mass Compile documentation</strong></td>
        <td><a href="https://github.com/sw-embed/web-sw-cor24-plsw/blob/main/docs/mass-compile.md">docs/mass-compile.md</a></td>
      </tr>
      <tr>
        <td><strong>Video walkthrough (YouTube)</strong></td>
        <td><a href="https://www.youtube.com/watch?v=9KQ3ohU4BHE">youtube.com/watch?v=9KQ3ohU4BHE</a></td>
      </tr>
      <tr>
        <td><strong>PL/SW (the language)</strong></td>
        <td><a href="https://github.com/sw-embed/sw-cor24-plsw">sw-embed/sw-cor24-plsw</a></td>
      </tr>
      <tr>
        <td><strong>Web demo source</strong></td>
        <td><a href="https://github.com/sw-embed/web-sw-cor24-plsw">sw-embed/web-sw-cor24-plsw</a></td>
      </tr>
      <tr>
        <td><strong>Prior posts</strong></td>
        <td><a href="/2026/04/16/tbt-cor24-basic-startrek-trs80-robot-chase/">TBT #9: UNIVAC Startrek, TRS-80 Adventures, and COR24 BASIC</a> · <a href="/2026/04/30/bucket-list-3d-source-new-languages-visible-compilers/#the-1980s-ancestors-mass-compile-and-pledit">Bucket List #3: Mass Compile + PL/EDIT context</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="the-aq-setting">The AQ Setting</h2>

<p>AQ ran on MVS/ESA. From the user’s seat, every interaction was a 3270 block-mode terminal: you didn’t type interactively the way you do at a modern shell — you filled in fields on a screen, pressed Enter (or a PF key), the <em>entire screen</em> went to the host, the host processed it, and a <em>new entire screen</em> came back. Round-trip latency was small only by the standards of the day. The medium shaped the workflow.</p>

<p>Hundreds of developers shared this system. They edited PL/X (IBM’s internal PL/I dialect, used for systems work and OS components) and System/370 assembler. They submitted batch compile jobs to JES2. They scheduled printouts (yes, <em>printouts</em> — a real cabinet of green-bar paper down the hall) or browsed compile output online. The two universal pain points of every developer’s day were:</p>

<ul>
  <li><strong>Authoring is slow</strong> because most of what a working programmer types every day is repetitive boilerplate — IF/ELSE framing, DO/END loops, DCL declarations, PROC headers, MACRODEF blocks — and the editor gives you no help avoiding it.</li>
  <li><strong>Turnaround is slow</strong> because submitting a compile job means joining a queue, and on a busy day that queue could be an hour long. By the time results came back, you had context-switched to a different program four times.</li>
</ul>

<p>Two colleagues built tools to push back on each of those. They were exactly the kind of <em>internal productivity tooling</em> that did not exist as products on the open market in 1985 — IDEs were a Macintosh / Smalltalk lab curiosity, Emacs existed but was a Unix-room thing not a mainframe thing, and the average corporate mainframe shop ran whatever editor IBM happened to ship. Internal tools filled the gap, and the good ones spread by reputation.</p>

<h2 id="pledit--templates-before-they-were-a-word">PL/EDIT — Templates Before They Were a Word</h2>

<p>A colleague (whose name I am withholding here, since I have not asked their consent to publish it forty years on) wrote <strong>PL/EDIT</strong>. The premise was simple: most of what a PL/X programmer typed was <em>boilerplate</em>. The first three lines of every IF block. The DO/END framing of every loop. The DCL statements at the top of every record declaration. The PROC header with its parameter list and RETURNS clause. The MACRODEF blocks. None of it was creative work. All of it was syntax.</p>

<p>PL/EDIT replaced character-by-character authoring with <em>trigger-driven template expansion</em>. You typed a short trigger like <code class="language-plaintext highlighter-rouge">IF</code> and pressed F4, and the editor expanded the trigger into a full IF/ELSE block with named fill fields. You pressed Tab to advance through the fields, Shift-Tab to go back. F4 cost one extra round trip to fetch the expanded screen, and the trade was easy: a few characters of trigger plus one round trip, in exchange for not typing the dozen-plus characters of boilerplate by hand.</p>

<p>The triggers covered every form a working programmer touched dozens of times a day — IF/ELSE blocks, DO WHILE and counted DO loops, SELECT/WHEN dispatch, scalar and record declarations, PROC headers with parameter lists and return types, CALL and RETURN, inline-assembler blocks, the macro-definition forms used for code generation. A help button opened the active trigger list; a Format button re-indented block structure that had gotten ragged.</p>

<p>This is <em>exactly</em> the model that snippets, IDE templates, and YAS-snippet would later mainstream in the 1990s and 2000s. PL/EDIT did it in the mid-1980s. It was not the first template editor anywhere — TECO and Emacs had abbrev-mode, and similar systems had snippets — but it was the only one that hundreds of us had access to, and the productivity difference between using it and not was night and day.</p>

<h2 id="pledit-on-cor24-plsw">PL/EDIT on COR24 PL/SW</h2>

<div class="gutter-section">

  <p><img src="/assets/images/posts/pl-edit-green.webp" class="gutter-img-right no-invert" alt="PL/EDIT in green LED letters on a black 3270-style screen" /></p>

  <p>The COR24 <a href="https://sw-embed.github.io/web-sw-cor24-plsw/">PL/SW live demo</a> is a Yew/WebAssembly application that hosts the PL/SW compiler, COR24 emulator, and a small source editor entirely in the browser. PL/EDIT is implemented there as a hotkey-driven editing mode you toggle with the <code class="language-plaintext highlighter-rouge">PL/EDIT</code> button in the editor header. The mechanism is faithful to the original idea: type a trigger, press F4 (or <code class="language-plaintext highlighter-rouge">Ctrl+Space</code>), and the trigger expands into a template with fill fields. <code class="language-plaintext highlighter-rouge">Tab</code> advances through fields; <code class="language-plaintext highlighter-rouge">Shift+Tab</code> goes back; <code class="language-plaintext highlighter-rouge">Ctrl+Enter</code> inserts a newline inside block content.</p>

  <p>The PL/SW source-editor trigger set:</p>

  <table>
    <thead>
      <tr>
        <th>Trigger</th>
        <th>Expansion</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">IF</code> / <code class="language-plaintext highlighter-rouge">IFS</code></td>
        <td>IF/ELSE block; single-statement IF</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">DW</code> / <code class="language-plaintext highlighter-rouge">DO</code></td>
        <td>DO WHILE; counted DO</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">SEL</code> / <code class="language-plaintext highlighter-rouge">WHEN</code></td>
        <td>SELECT/WHEN dispatch; WHEN branch</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">DCL</code> / <code class="language-plaintext highlighter-rouge">REC</code> / <code class="language-plaintext highlighter-rouge">BASED</code></td>
        <td>scalar / level / BASED record declaration</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">P</code> / <code class="language-plaintext highlighter-rouge">PR</code> / <code class="language-plaintext highlighter-rouge">NAK</code></td>
        <td>PROC; PROC with RETURNS; OPTIONS(NAKED) PROC</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">ASM</code></td>
        <td>ASM DO block (inline assembler)</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">CALL</code> / <code class="language-plaintext highlighter-rouge">RET</code> / <code class="language-plaintext highlighter-rouge">RETV</code> / <code class="language-plaintext highlighter-rouge">G</code></td>
        <td>CALL; RETURN expression; void RETURN; GOTO</td>
      </tr>
    </tbody>
  </table>

  <p>Plus a complementary set for <code class="language-plaintext highlighter-rouge">.msw</code> macro-include files (a PL/SW invention — <code class="language-plaintext highlighter-rouge">.msw</code> is the PL/SW analogue of a header file with macro power): <code class="language-plaintext highlighter-rouge">MD</code> for MACRODEF, <code class="language-plaintext highlighter-rouge">REQ</code>/<code class="language-plaintext highlighter-rouge">OPT</code> for required/optional parameters, <code class="language-plaintext highlighter-rouge">GEN</code> for GEN DO blocks, <code class="language-plaintext highlighter-rouge">INC</code> for <code class="language-plaintext highlighter-rouge">%INCLUDE</code>, <code class="language-plaintext highlighter-rouge">INV</code> for invocation. The <code class="language-plaintext highlighter-rouge">?</code> button shows the active trigger list. <code class="language-plaintext highlighter-rouge">Format</code> does PL/I-style re-indentation of block structure (<code class="language-plaintext highlighter-rouge">PROC</code>, <code class="language-plaintext highlighter-rouge">IF/THEN/ELSE</code>, <code class="language-plaintext highlighter-rouge">DO WHILE</code>, counted <code class="language-plaintext highlighter-rouge">DO</code>, <code class="language-plaintext highlighter-rouge">SELECT</code>/<code class="language-plaintext highlighter-rouge">WHEN</code>/<code class="language-plaintext highlighter-rouge">OTHERWISE</code>, <code class="language-plaintext highlighter-rouge">ASM DO</code>, <code class="language-plaintext highlighter-rouge">GEN DO</code>, <code class="language-plaintext highlighter-rouge">MACRODEF</code>, multi-line <code class="language-plaintext highlighter-rouge">DCL</code> records). Full reference in <a href="https://github.com/sw-embed/web-sw-cor24-plsw/blob/main/docs/pl-edit.md">docs/pl-edit.md</a>.</p>

</div>

<h2 id="mass-compile--submitting-jobs-for-programs-you-had-not-written-yet">Mass Compile — Submitting Jobs for Programs You Had Not Written Yet</h2>

<p>A different colleague tackled the <em>turnaround</em> problem with <strong>Mass Compile</strong>.</p>

<p>The naive workflow on AQ went: edit a program, save it, submit a compile job, wait. The wait could be five minutes; it could be an hour. Whatever the wait was, you context-switched, and when results came back you context-switched back. If you had a stack of related changes across several programs, you submitted them serially — finish program A, submit, wait, switch to program B, edit, save, submit, wait. The queue and the editor were <em>unsynchronized</em>: time you spent editing was time the compile queue was not running on your behalf.</p>

<p>Mass Compile was a screen — a single 3270 panel — where you could <em>schedule</em> a batch of compile jobs in advance. The trick the screen made possible was the one I still find astonishing: <strong>you could submit compile jobs for programs you had not written yet</strong>. The compiler did not know that. The compiler did know that when its turn arrived in the queue, it would go look up the named source member in the editor’s working storage and compile <em>whatever was there at that moment</em>. So:</p>

<ol>
  <li>You scheduled jobs for programs A, B, C, D — four compiles, queued in order.</li>
  <li>While the queue waited for A’s slot to open, you finished editing A.</li>
  <li>While A was compiling, you finished editing B.</li>
  <li>While B was compiling, you finished editing C.</li>
  <li>While C was compiling, you finished editing D.</li>
</ol>

<p>The queue and the editor were now <em>synchronized</em>: every minute the queue spent moving forward was a minute the editor spent moving forward. The total wall-clock time to compile four related changes dropped from “four queue-waits in series” to “one queue-wait plus four edits in parallel with three compiles.”</p>

<p>The catch was the file lock. The editor held an OS-level file lock on each source member while you were editing it; JES2 needed the same lock to read that source when the compile job reached the head of the queue. If your job got there before you finished editing, JES2 waited on you. Messages would scroll into the bottom of your screen telling you, then telling you more emphatically, that a compile job was blocked waiting for your editor to release the lock. Senior engineers learned the social cost of holding the queue on a busy day; you did not want operations or your manager to start wondering why nobody else’s compiles were moving.</p>

<p>This was, in its own way, the first JIT-style “compile under pressure” workflow I ever saw. The compile job <em>did not block</em> on the source being final at submit time. The source was final <em>as of the moment JES2 acquired the file lock</em>, not as of submission. Speculative scheduling, OS-level file-lock arbitration, and a small dose of social pressure to keep you honest. The trick has not really gone away — modern build systems (Bazel, Buck, Cargo) all have variations on “kick off compute against the inputs as soon as they stabilize,” and CI systems do something analogous with branch-based job queues. But none of them <em>show</em> you the queue moving the way the AQ Mass Compile screen did.</p>

<h2 id="mass-compile-on-cor24-plsw">Mass Compile on COR24 PL/SW</h2>

<div class="gutter-section">

  <p><img src="/assets/images/posts/mass-compile-green.webp" class="gutter-img-left no-invert" alt="Mass Compile in green LED letters on a black 3270-style screen" /></p>

  <p>The COR24 PL/SW live demo has no JES2 and no OS-level file locks — it runs entirely in WebAssembly with the compiler and emulator embedded in the page. What the demo <em>does</em> preserve is the speculative-scheduling shape and the queue-vs-edit pacing, recast as a homage rather than a faithful port.</p>

  <p>Open the dialog from the source editor’s action row. The left panel is a job list; you add rows, pick demos, optionally edit per-row scratch source, then <code class="language-plaintext highlighter-rouge">Submit</code> (one row) or <code class="language-plaintext highlighter-rouge">Submit All</code> (every row). Jobs run sequentially through the states <code class="language-plaintext highlighter-rouge">queued</code> → <code class="language-plaintext highlighter-rouge">compiling</code> → <code class="language-plaintext highlighter-rouge">assembling</code> → <code class="language-plaintext highlighter-rouge">running</code> → <code class="language-plaintext highlighter-rouge">complete</code> (or <code class="language-plaintext highlighter-rouge">failed</code>). Submitted jobs do not modify the bundled demo source; they compile from browser drafts. If a queued job has no scratch edit, it snapshots the current draft for that demo when the job <em>enters</em> the <code class="language-plaintext highlighter-rouge">compiling</code> state, not when the job is queued. So you can keep editing program A while program B is compiling.</p>

  <p>The lock-in-spirit is the <code class="language-plaintext highlighter-rouge">waiting</code> state. If a queued job reaches the head of the queue while its scratch editor is still dirty (you have not pressed <code class="language-plaintext highlighter-rouge">Save</code>), the job state changes to <code class="language-plaintext highlighter-rouge">waiting</code> and the queue stops there until you save. That is <em>not</em> a file lock; it is a save-vs-unsaved sentinel. The mechanism is different from JES2’s; the role it plays is the same — the queue does not move until your edit is committed.</p>

  <p>Full reference in <a href="https://github.com/sw-embed/web-sw-cor24-plsw/blob/main/docs/mass-compile.md">docs/mass-compile.md</a>.</p>

</div>

<h2 id="why-these-two-keep-coming-back">Why These Two Keep Coming Back</h2>

<p>PL/EDIT and Mass Compile are about <em>making each interaction with a slow system carry more weight</em>. PL/EDIT trades a single F4 round trip for a dozen-plus characters of typing you would otherwise do by hand; Mass Compile lets the queue move while you keep editing. Both are productivity multipliers in environments where the dominant cost is wait time.</p>

<p>I keep finding the same shapes today, in different surfaces:</p>

<ul>
  <li><strong>Snippets and LSP scaffolds</strong> in modern editors are PL/EDIT’s children: type a trigger, get a templated form with fill fields. Same idea: stop typing the boilerplate, fill in only the parts that change.</li>
  <li><strong>CI parallelism, build queues, and content-addressed caches</strong> are Mass Compile’s children: do not block on the source being final at submit time; do the expensive thing as late as possible against whatever inputs are stable; let the queue move forward in parallel with editing. <a href="/2026/04/28/personal-software-sw-launcher-one-ring/">sw-launcher</a>’s cache-key formula is a content-addressed version of the same idea — the work runs against the inputs that exist <em>when it runs</em>, not <em>when it was scheduled</em>.</li>
  <li><strong>AI agents working from a queue of tasks</strong> are Mass Compile’s grandchildren: submit a sequence of work items; let the agent process them while you keep going; surface warnings when an item is blocked on input you have not provided yet.</li>
</ul>

<p>The 1980s mainframe shop was not primitive. It was <em>constrained</em> — a different set of constraints than today’s, but the people working in it solved their constraints with surprising elegance, and the productivity tooling that survived from that era keeps re-emerging in modern surfaces because the underlying problems — slow authoring, slow turnaround, queue contention — have only changed in detail. The medium changes; the moves stay.</p>

<p>If you have a few minutes, <a href="https://sw-embed.github.io/web-sw-cor24-plsw/">the live demo</a> is worth poking at. Type <code class="language-plaintext highlighter-rouge">IF</code> and press F4. Open Mass Compile, add a few rows, edit one of them while the queue runs ahead. The 3270 is gone; the workflow is intact.</p>

<p>Login <code class="language-plaintext highlighter-rouge">AUTHORIZED USERS ONLY</code>. ONLINE 24,1.</p>]]></content><author><name>Software Wrighter</name></author><category term="tbt" /><category term="programming-history" /><category term="retrocomputing" /><category term="compilers" /><category term="throwback-thursday" /><category term="mass-compile" /><category term="pl-edit" /><category term="plsw" /><category term="plx" /><category term="mvs" /><category term="aq-system" /><category term="3270" /><category term="batch-jobs" /><category term="time-sharing" /><category term="ibm-mainframe" /><category term="retro-computing" /><category term="cor24" /><summary type="html"><![CDATA[A green screen, an ASCII-art AQ logo a foot wide, and the words WELCOME TO THE AQ SYSTEM / AUTHORIZED USERS ONLY over a blinking LOGIN: prompt. PF1=HELP, PF2=LOGON, PF3=LOGOFF, PF4=CHANGE PASSWORD, PF12=CANCEL. If you ever logged into a corporate IBM mainframe in the 1980s, you saw a screen that looked roughly like this; if you worked at IBM in those years, the screen was attached to your day for as long as a typical workday lasted. AQ — the “A queue” — was the MVS/ESA time-sharing and batch system where I worked on PL/X systems code, and the entire developer experience for hundreds of engineers ran through it. Resource Link PL/SW Live Demo sw-embed.github.io/web-sw-cor24-plsw PL/EDIT documentation docs/pl-edit.md Mass Compile documentation docs/mass-compile.md Video walkthrough (YouTube) youtube.com/watch?v=9KQ3ohU4BHE PL/SW (the language) sw-embed/sw-cor24-plsw Web demo source sw-embed/web-sw-cor24-plsw Prior posts TBT #9: UNIVAC Startrek, TRS-80 Adventures, and COR24 BASIC · Bucket List #3: Mass Compile + PL/EDIT context Comments Discord The AQ Setting AQ ran on MVS/ESA. From the user’s seat, every interaction was a 3270 block-mode terminal: you didn’t type interactively the way you do at a modern shell — you filled in fields on a screen, pressed Enter (or a PF key), the entire screen went to the host, the host processed it, and a new entire screen came back. Round-trip latency was small only by the standards of the day. The medium shaped the workflow. Hundreds of developers shared this system. They edited PL/X (IBM’s internal PL/I dialect, used for systems work and OS components) and System/370 assembler. They submitted batch compile jobs to JES2. They scheduled printouts (yes, printouts — a real cabinet of green-bar paper down the hall) or browsed compile output online. The two universal pain points of every developer’s day were: Authoring is slow because most of what a working programmer types every day is repetitive boilerplate — IF/ELSE framing, DO/END loops, DCL declarations, PROC headers, MACRODEF blocks — and the editor gives you no help avoiding it. Turnaround is slow because submitting a compile job means joining a queue, and on a busy day that queue could be an hour long. By the time results came back, you had context-switched to a different program four times. Two colleagues built tools to push back on each of those. They were exactly the kind of internal productivity tooling that did not exist as products on the open market in 1985 — IDEs were a Macintosh / Smalltalk lab curiosity, Emacs existed but was a Unix-room thing not a mainframe thing, and the average corporate mainframe shop ran whatever editor IBM happened to ship. Internal tools filled the gap, and the good ones spread by reputation. PL/EDIT — Templates Before They Were a Word A colleague (whose name I am withholding here, since I have not asked their consent to publish it forty years on) wrote PL/EDIT. The premise was simple: most of what a PL/X programmer typed was boilerplate. The first three lines of every IF block. The DO/END framing of every loop. The DCL statements at the top of every record declaration. The PROC header with its parameter list and RETURNS clause. The MACRODEF blocks. None of it was creative work. All of it was syntax. PL/EDIT replaced character-by-character authoring with trigger-driven template expansion. You typed a short trigger like IF and pressed F4, and the editor expanded the trigger into a full IF/ELSE block with named fill fields. You pressed Tab to advance through the fields, Shift-Tab to go back. F4 cost one extra round trip to fetch the expanded screen, and the trade was easy: a few characters of trigger plus one round trip, in exchange for not typing the dozen-plus characters of boilerplate by hand. The triggers covered every form a working programmer touched dozens of times a day — IF/ELSE blocks, DO WHILE and counted DO loops, SELECT/WHEN dispatch, scalar and record declarations, PROC headers with parameter lists and return types, CALL and RETURN, inline-assembler blocks, the macro-definition forms used for code generation. A help button opened the active trigger list; a Format button re-indented block structure that had gotten ragged. This is exactly the model that snippets, IDE templates, and YAS-snippet would later mainstream in the 1990s and 2000s. PL/EDIT did it in the mid-1980s. It was not the first template editor anywhere — TECO and Emacs had abbrev-mode, and similar systems had snippets — but it was the only one that hundreds of us had access to, and the productivity difference between using it and not was night and day. PL/EDIT on COR24 PL/SW The COR24 PL/SW live demo is a Yew/WebAssembly application that hosts the PL/SW compiler, COR24 emulator, and a small source editor entirely in the browser. PL/EDIT is implemented there as a hotkey-driven editing mode you toggle with the PL/EDIT button in the editor header. The mechanism is faithful to the original idea: type a trigger, press F4 (or Ctrl+Space), and the trigger expands into a template with fill fields. Tab advances through fields; Shift+Tab goes back; Ctrl+Enter inserts a newline inside block content. The PL/SW source-editor trigger set: Trigger Expansion IF / IFS IF/ELSE block; single-statement IF DW / DO DO WHILE; counted DO SEL / WHEN SELECT/WHEN dispatch; WHEN branch DCL / REC / BASED scalar / level / BASED record declaration P / PR / NAK PROC; PROC with RETURNS; OPTIONS(NAKED) PROC ASM ASM DO block (inline assembler) CALL / RET / RETV / G CALL; RETURN expression; void RETURN; GOTO Plus a complementary set for .msw macro-include files (a PL/SW invention — .msw is the PL/SW analogue of a header file with macro power): MD for MACRODEF, REQ/OPT for required/optional parameters, GEN for GEN DO blocks, INC for %INCLUDE, INV for invocation. The ? button shows the active trigger list. Format does PL/I-style re-indentation of block structure (PROC, IF/THEN/ELSE, DO WHILE, counted DO, SELECT/WHEN/OTHERWISE, ASM DO, GEN DO, MACRODEF, multi-line DCL records). Full reference in docs/pl-edit.md. Mass Compile — Submitting Jobs for Programs You Had Not Written Yet A different colleague tackled the turnaround problem with Mass Compile. The naive workflow on AQ went: edit a program, save it, submit a compile job, wait. The wait could be five minutes; it could be an hour. Whatever the wait was, you context-switched, and when results came back you context-switched back. If you had a stack of related changes across several programs, you submitted them serially — finish program A, submit, wait, switch to program B, edit, save, submit, wait. The queue and the editor were unsynchronized: time you spent editing was time the compile queue was not running on your behalf. Mass Compile was a screen — a single 3270 panel — where you could schedule a batch of compile jobs in advance. The trick the screen made possible was the one I still find astonishing: you could submit compile jobs for programs you had not written yet. The compiler did not know that. The compiler did know that when its turn arrived in the queue, it would go look up the named source member in the editor’s working storage and compile whatever was there at that moment. So: You scheduled jobs for programs A, B, C, D — four compiles, queued in order. While the queue waited for A’s slot to open, you finished editing A. While A was compiling, you finished editing B. While B was compiling, you finished editing C. While C was compiling, you finished editing D. The queue and the editor were now synchronized: every minute the queue spent moving forward was a minute the editor spent moving forward. The total wall-clock time to compile four related changes dropped from “four queue-waits in series” to “one queue-wait plus four edits in parallel with three compiles.” The catch was the file lock. The editor held an OS-level file lock on each source member while you were editing it; JES2 needed the same lock to read that source when the compile job reached the head of the queue. If your job got there before you finished editing, JES2 waited on you. Messages would scroll into the bottom of your screen telling you, then telling you more emphatically, that a compile job was blocked waiting for your editor to release the lock. Senior engineers learned the social cost of holding the queue on a busy day; you did not want operations or your manager to start wondering why nobody else’s compiles were moving. This was, in its own way, the first JIT-style “compile under pressure” workflow I ever saw. The compile job did not block on the source being final at submit time. The source was final as of the moment JES2 acquired the file lock, not as of submission. Speculative scheduling, OS-level file-lock arbitration, and a small dose of social pressure to keep you honest. The trick has not really gone away — modern build systems (Bazel, Buck, Cargo) all have variations on “kick off compute against the inputs as soon as they stabilize,” and CI systems do something analogous with branch-based job queues. But none of them show you the queue moving the way the AQ Mass Compile screen did. Mass Compile on COR24 PL/SW The COR24 PL/SW live demo has no JES2 and no OS-level file locks — it runs entirely in WebAssembly with the compiler and emulator embedded in the page. What the demo does preserve is the speculative-scheduling shape and the queue-vs-edit pacing, recast as a homage rather than a faithful port. Open the dialog from the source editor’s action row. The left panel is a job list; you add rows, pick demos, optionally edit per-row scratch source, then Submit (one row) or Submit All (every row). Jobs run sequentially through the states queued → compiling → assembling → running → complete (or failed). Submitted jobs do not modify the bundled demo source; they compile from browser drafts. If a queued job has no scratch edit, it snapshots the current draft for that demo when the job enters the compiling state, not when the job is queued. So you can keep editing program A while program B is compiling. The lock-in-spirit is the waiting state. If a queued job reaches the head of the queue while its scratch editor is still dirty (you have not pressed Save), the job state changes to waiting and the queue stops there until you save. That is not a file lock; it is a save-vs-unsaved sentinel. The mechanism is different from JES2’s; the role it plays is the same — the queue does not move until your edit is committed. Full reference in docs/mass-compile.md. Why These Two Keep Coming Back PL/EDIT and Mass Compile are about making each interaction with a slow system carry more weight. PL/EDIT trades a single F4 round trip for a dozen-plus characters of typing you would otherwise do by hand; Mass Compile lets the queue move while you keep editing. Both are productivity multipliers in environments where the dominant cost is wait time. I keep finding the same shapes today, in different surfaces: Snippets and LSP scaffolds in modern editors are PL/EDIT’s children: type a trigger, get a templated form with fill fields. Same idea: stop typing the boilerplate, fill in only the parts that change. CI parallelism, build queues, and content-addressed caches are Mass Compile’s children: do not block on the source being final at submit time; do the expensive thing as late as possible against whatever inputs are stable; let the queue move forward in parallel with editing. sw-launcher’s cache-key formula is a content-addressed version of the same idea — the work runs against the inputs that exist when it runs, not when it was scheduled. AI agents working from a queue of tasks are Mass Compile’s grandchildren: submit a sequence of work items; let the agent process them while you keep going; surface warnings when an item is blocked on input you have not provided yet. The 1980s mainframe shop was not primitive. It was constrained — a different set of constraints than today’s, but the people working in it solved their constraints with surprising elegance, and the productivity tooling that survived from that era keeps re-emerging in modern surfaces because the underlying problems — slow authoring, slow turnaround, queue contention — have only changed in detail. The medium changes; the moves stay. If you have a few minutes, the live demo is worth poking at. Type IF and press F4. Open Mass Compile, add a few rows, edit one of them while the queue runs ahead. The 3270 is gone; the workflow is intact. Login AUTHORIZED USERS ONLY. ONLINE 24,1.]]></summary></entry><entry><title type="html">Bucket List #3: 3D Source Code, Five New Languages, and Visible Compilers</title><link href="https://blog.softwarewrighter.com/2026/04/30/bucket-list-3d-source-new-languages-visible-compilers/" rel="alternate" type="text/html" title="Bucket List #3: 3D Source Code, Five New Languages, and Visible Compilers" /><published>2026-04-30T12:00:00-07:00</published><updated>2026-04-30T12:00:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/04/30/bucket-list-3d-source-new-languages-visible-compilers</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/04/30/bucket-list-3d-source-new-languages-visible-compilers/"><![CDATA[<p><img src="/assets/images/posts/block-mag-glyphs.webp" class="post-marker no-invert" alt="" style="width: 220px;" /></p>

<div style="overflow: hidden;">

  <p>The first two posts in this series listed the things I always wanted to build (<a href="/2026/03/21/bucket-list-things-ive-always-wanted-to-build/">part 1</a>) and the surprisingly long list that got crossed off in two weeks of vibe-coding (<a href="/2026/04/03/bucket-list-software-tools-landing-page/">part 2</a>). Three categories of work currently pulling at me are <em>not</em> yet on either list. They belong on the list. This post adds them.</p>

</div>

<div class="aside-box">

  <p><strong>Why this matters</strong> — A bucket list is only useful if it grows as fast as it shrinks. Crossing things off without adding things back is how a list gets shorter than the curiosities of the person carrying it. The three categories below are what’s been quietly moving from “interesting” to “I’m actually doing this” since the last post — and they share a thread: each one is something a working engineer rarely gets to do (build a new programming language, sculpt a new authoring surface, or instrument a compiler so you can <em>watch</em> it think) because the day job never gives that kind of room. Retirement and AI agents jointly do.</p>

</div>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>DiscoveryOne</strong> (3D source)</td>
        <td><a href="https://github.com/softwarewrighter/DiscoveryOne">softwarewrighter/DiscoveryOne</a></td>
      </tr>
      <tr>
        <td><strong>Tuplet</strong> (2D, DiscoveryOne’s predecessor)</td>
        <td><a href="https://github.com/softwarewrighter/tuplet">softwarewrighter/tuplet</a></td>
      </tr>
      <tr>
        <td><strong>sw-MLPL</strong> (array language)</td>
        <td><a href="https://github.com/sw-ml-study/sw-mlpl">sw-ml-study/sw-mlpl</a> · <a href="https://sw-ml-study.github.io/sw-mlpl/">live REPL</a></td>
      </tr>
      <tr>
        <td><strong>PL/SW</strong> (PL/I-inspired systems lang)</td>
        <td><a href="https://github.com/sw-embed/sw-cor24-plsw">sw-embed/sw-cor24-plsw</a> · <a href="https://sw-embed.github.io/web-sw-cor24-plsw/">live demo</a></td>
      </tr>
      <tr>
        <td><strong>SWS</strong> (Tcl-like resident shell)</td>
        <td><a href="https://github.com/sw-embed/sw-cor24-script">sw-embed/sw-cor24-script</a></td>
      </tr>
      <tr>
        <td><strong>Bucket List</strong></td>
        <td><a href="https://github.com/softwarewrighter/bucketlist">softwarewrighter/bucketlist</a></td>
      </tr>
      <tr>
        <td><strong>Prior posts</strong></td>
        <td><a href="/2026/03/21/bucket-list-things-ive-always-wanted-to-build/">Part 1</a> · <a href="/2026/04/03/bucket-list-software-tools-landing-page/">Part 2</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="source-codes-third-dimension">Source Code’s Third Dimension</h2>

<p>Programmers get attached to the medium they happened to learn on. People who started on punch cards remember source as a <em>one-dimensional</em> thing: a stack of cards, fed sequentially, each card eighty columns of fixed-width sequencing, each program a literal physical pile. People who started on glass terminals (which is most of us) think of source as <em>two-dimensional</em>: a window of lines and columns, scrollable in two axes, with the assumption that the meaningful structure lives inside that rectangle.</p>

<p>Every editor we use today is still a 2D-rectangle authoring surface. We have learned to interleave many concerns inside that rectangle — the <em>algorithm</em>, the <em>types of inputs and outputs</em>, the <em>preconditions</em> and <em>postconditions</em> the algorithm assumes, the <em>generated form</em> the compiler ends up emitting, the <em>implementation layers</em> (logging, error handling, instrumentation) that production code accumulates. Modern languages provide affordances for hiding most of this — type signatures collapse, comments fold, error handling moves to attributes or decorators — but it all still lives in the same rectangle, fighting for the same screen real estate, and the front-of-the-eye reading order is whatever the editor’s vertical scrollbar decides.</p>

<p>Punch cards are 1D. Screens are 2D. <strong>What if source were 3D?</strong></p>

<p><a href="https://github.com/softwarewrighter/DiscoveryOne"><strong>DiscoveryOne</strong></a> is the project I am sketching to make that question concrete. Every glyph in a DiscoveryOne program has an <code class="language-plaintext highlighter-rouge">(x, y, z)</code> coordinate and an aspect label drawn from a fixed set: <code class="language-plaintext highlighter-rouge">@front</code>, <code class="language-plaintext highlighter-rouge">@left</code>, <code class="language-plaintext highlighter-rouge">@right</code>, <code class="language-plaintext highlighter-rouge">@top</code>, <code class="language-plaintext highlighter-rouge">@bottom</code>, <code class="language-plaintext highlighter-rouge">@rear</code>, <code class="language-plaintext highlighter-rouge">@internal</code>. A definition is <em>not</em> a block of text; it is a small cube of meaning, and the user views one facet at a time:</p>

<table>
  <thead>
    <tr>
      <th>Facet</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Front</td>
      <td>Algorithm gist — the readable story</td>
    </tr>
    <tr>
      <td>Left</td>
      <td>Inputs (arity, names, types)</td>
    </tr>
    <tr>
      <td>Right</td>
      <td>Outputs (arity, names, types)</td>
    </tr>
    <tr>
      <td>Top</td>
      <td>Preconditions</td>
    </tr>
    <tr>
      <td>Bottom</td>
      <td>Postconditions</td>
    </tr>
    <tr>
      <td>Rear</td>
      <td>Generated form (WAT or stack IR)</td>
    </tr>
    <tr>
      <td>Internal</td>
      <td>Implementation in spatially-separable layers</td>
    </tr>
  </tbody>
</table>

<p>The Yew/WASM web app loads the file, projects it onto the requested facet, and runs the WASM module in-page when you click <strong>Run</strong>. A <code class="language-plaintext highlighter-rouge">*Power</code> definition reading <code class="language-plaintext highlighter-rouge">n e -&gt; p; p &lt;- 1; loop e times: p &lt;- p * n</code> lives on <code class="language-plaintext highlighter-rouge">@front</code>; <code class="language-plaintext highlighter-rouge">n : Int, e : Int</code> lives on <code class="language-plaintext highlighter-rouge">@left</code>; the output type <code class="language-plaintext highlighter-rouge">p : Int</code> lives on <code class="language-plaintext highlighter-rouge">@right</code>; <code class="language-plaintext highlighter-rouge">e &gt;= 0</code> lives on <code class="language-plaintext highlighter-rouge">@top</code>; <code class="language-plaintext highlighter-rouge">p == n^e</code> lives on <code class="language-plaintext highlighter-rouge">@bottom</code>. Aspects (preconditions, postconditions, tracing, profiling, error recovery) live on spatially separate <code class="language-plaintext highlighter-rouge">@internal</code> layers so the front facet stays uncluttered for reading.</p>

<p>DiscoveryOne is the successor to <strong>Tuplet</strong>, my current 2D-layout-sensitive language with first-class named tuples and user-mintable verbs (the <code class="language-plaintext highlighter-rouge">*</code> operator literally mints new syntax). Tuplet keeps source 2D but layout-sensitive — <em>where</em> a glyph sits on a 2D grid changes its meaning. DiscoveryOne is the next jump: from “layout matters” to “facet matters.” The same glyph in two different <code class="language-plaintext highlighter-rouge">@</code>-aspects participates in two different parts of the program’s meaning.</p>

<p>Whether 3D source is <em>useful</em> is a real question. It might turn out that the seven facets are too many, or that the projection UI is too clumsy, or that humans really do read code best as a flat top-to-bottom story. I don’t know. The way to find out is to build it, drive a non-trivial program through it, and see whether reading the front facet of an unfamiliar definition is faster than reading the equivalent flat code with all its types and contracts and tracing inline.</p>

<p>DiscoveryOne is currently pre-M0 — the specification is committed; no code yet. The single demoable target (M7 in the saga plan) is one vertical slice: the user authors a <code class="language-plaintext highlighter-rouge">*Power</code> definition and a <code class="language-plaintext highlighter-rouge">*syntax do _ while _ end expand</code> syntax declaration, and runs both inside the web app. If that slice feels right, the rest follows.</p>

<h2 id="five-languages-of-my-own-in-flight">Five Languages of My Own, in Flight</h2>

<p>The other thing on the list right now is <em>making programming languages</em>, plural. Five of mine are currently between “design committed” and “live demo,” each picking a different point on the build/run space. Listed in roughly the order I started them:</p>

<h3 id="plsw--pli-inspired-systems-language">PL/SW — PL/I-Inspired Systems Language</h3>

<p><a href="https://github.com/sw-embed/sw-cor24-plsw"><strong>sw-cor24-plsw</strong></a> is a small systems-programming language inspired by PL/I (and a little IBM HLASM). It is the language I use to write higher-level COR24 programs — the SNOBOL4 interpreter, parts of the toolchain, the Fortran compiler in flight. It compiles natively to COR24 assembly and has its own <a href="/2026/04/29/personal-software-vibe-maintenance/">vibe-maintenance heatmap</a> of issues closed in the past few weeks (forty-plus, including the inevitable parade of “AST pool too small,” “MAX_PROCS too small,” “emit buffer too small” capacity bumps).</p>

<p>PL/SW’s interesting bet is <em>macros</em>: PL/I-style <code class="language-plaintext highlighter-rouge">%DEFINE</code> and <code class="language-plaintext highlighter-rouge">MACRODEF GEN</code> blocks that emit assembly. That makes the language usable as a meta-assembler — a higher-level surface that still gives you bit-level control over the COR24 instruction stream. It is the language I would have wanted in 1985 if I had had any say in the matter.</p>

<h3 id="sws--tcl-like-resident-shell">SWS — Tcl-Like Resident Shell</h3>

<p><a href="https://github.com/sw-embed/sw-cor24-script"><strong>sw-cor24-script</strong></a> is a tiny Tcl-style scripting language that runs <em>inside</em> the resident monitor on COR24, sharing the program registry. The shell is a single binary that loads at <code class="language-plaintext highlighter-rouge">0x020000</code> (above the monitor at zero), and its commands operate on whatever programs the monitor has loaded into the slot table. It is the missing surface for a “1980s style” embedded workflow — the user types <code class="language-plaintext highlighter-rouge">run hello</code> at a prompt, the monitor’s service vector dispatches into the program at that slot, and control flow returns through a longjmp-style trampoline.</p>

<p>SWS exists because every other language in the lab is <em>non-resident</em> — a load happens, a program runs, the run ends, the host runs the next thing. SWS is the language designed for the case where the <em>user</em> is part of the loop: type, run, observe, type again. The repl on a 1 MiB COR24, with a 3 KiB EBR stack, in the year 2026.</p>

<h3 id="sw-mlpl--a-rust-first-array-language">sw-MLPL — A Rust-First Array Language</h3>

<p><a href="https://github.com/sw-ml-study/sw-mlpl"><strong>sw-mlpl</strong></a> is the array-and-tensor programming language I am building for the ML side of the bucket list. The lineage is APL → APL2 → J → BQN, but the <em>implementation</em> is Rust-first: a REPL in the terminal and the browser, a <code class="language-plaintext highlighter-rouge">mlpl!</code> proc macro that lets MLPL expressions live inside Rust source, an <code class="language-plaintext highlighter-rouge">mlpl build</code> path that compiles MLPL programs to native binaries, and a roadmap of backends (Apple MLX for Apple Silicon, CUDA for distributed training, Ollama / llama.cpp / OpenAI-compatible servers for LLM glue).</p>

<p>MLPL is the only one of the five that is mostly <em>built</em> — the live REPL works in the browser today, the language reference is written, the compiler implementation has a tour document for educational reading. What’s still ahead is the long tail of array-language features (rank polymorphism, fork composition, J-style tacit programming) and the big backends. It is the language I will use to do the <a href="/2026/03/21/bucket-list-things-ive-always-wanted-to-build/">fine-tune-a-base-model</a> item from part 1.</p>

<h3 id="tuplet--2d-layout-sensitive-language-with-mintable-verbs">Tuplet — 2D Layout-Sensitive Language with Mintable Verbs</h3>

<p><a href="https://github.com/softwarewrighter/tuplet"><strong>tuplet</strong></a> is the language I am driving as my main daily-language experiment. It is 2D-layout-sensitive (where a glyph sits on a grid matters), has first-class named tuples and multi-output verbs, and lets the user <em>mint</em> new verbs and new syntax via the <code class="language-plaintext highlighter-rouge">*</code> operator. The kernel is small; everything else — including control flow — is library code expressed in the kernel. The host is an OCaml-subset interpreter; the runtime target is Forth running on the COR24 emulator.</p>

<p>Tuplet is currently in the “wakes the dragons” phase of language development — the language compiles, demos run, but the OCaml interpreter underneath has been <a href="/2026/04/28/personal-software-sw-launcher-one-ring/">thrashing its heap</a> until the GC work in flight (<code class="language-plaintext highlighter-rouge">sw-cor24-ocaml#28</code>) lands. Once that lands, Tuplet’s <code class="language-plaintext highlighter-rouge">heap_limit</code> should <em>shrink</em>, not stay where it is, and the language will start to feel less fragile.</p>

<h3 id="discoveryone--3d-successor">DiscoveryOne — 3D Successor</h3>

<p>Already covered above. DiscoveryOne is to Tuplet what Tuplet is to a flat language: one more dimension of authoring surface, one more affordance for separating concerns spatially rather than syntactically. It is the place where I get to ask the broader question — <em>can authoring be 3D?</em> — without bolting it onto a language whose users are already doing real work.</p>

<h2 id="the-1980s-ancestors-mass-compile-and-pledit">The 1980s Ancestors: Mass Compile and PL/EDIT</h2>

<p>Two of the threads above have ancestors I worked on as a junior engineer at IBM in the 1980s — <strong>Mass Compile</strong>, a screen for scheduling batch compile jobs in advance (including jobs for code you had not finished writing), and <strong>PL/EDIT</strong>, a template-driven editor that expanded triggers into boilerplate via hotkey. Both ran on AQ, an MVS/ESA time-sharing service. The <a href="/2026/04/30/tbt-mass-compile-pl-edit-aq-system/">Throwback Thursday post</a> tells the story in detail. The reason they come up here is the conceptual lineage: Mass Compile’s “do the expensive thing against whatever inputs are stable when it runs” is the same shape as <a href="/2026/04/28/personal-software-sw-launcher-one-ring/">sw-launcher</a>’s content-addressed cache, and PL/EDIT’s “fill in the slot that matters and let the template handle the rest” is the same shape as DiscoveryOne’s facet authoring. Both ideas keep coming back, in different shapes, every time the dev loop gets a new bottleneck.</p>

<p>I maintained both tools; I did not write them. That was the right level for me at the time, and the <a href="/2026/04/29/personal-software-vibe-maintenance/">vibe-maintenance post</a> reprises the lesson forty years later: a tool you maintain long enough teaches you the design choices its author made and the seams where the next idea wants to break in. The bucket list, on this reading, is partly a list of the bottlenecks I have seen over the years and the surfaces I want to build to push back on them.</p>

<h2 id="visible-compilers--the-missing-tool-category">Visible Compilers — The Missing Tool Category</h2>

<p>The unifying gap, behind all of the above, is <em>visibility</em>.</p>

<p>Almost every CS curriculum spends a semester on compilers. Almost every working engineer uses one every day. Almost no engineer has ever <em>watched</em> a compiler run — watched the lexer turn a stream of characters into tokens, watched the parser grow an AST, watched a type checker fail and recover, watched a register allocator color a graph, watched a heap fill up and a GC reclaim it. The mechanism is invisible. We read about it. We trust the diagrams in textbooks. We never see the diagram move.</p>

<p>The next chunk of the bucket list is the tooling that fixes that. For each of the five languages above (and ideally for the COR24 toolchain in general), I want a visible counterpart:</p>

<h3 id="step-through-lexer">Step-Through Lexer</h3>

<p>A panel that shows the source on the left and the token stream on the right. Click “step” — the cursor advances by one token, the new token glows in the right panel, the consumed characters fade in the left panel. Speed it up to “auto” and the whole stream animates past at one-token-per-50ms. <em>See</em> the lexer.</p>

<h3 id="animated-parser">Animated Parser</h3>

<p>The same idea for the parser: source on the left, the AST growing on the right as a tree. Each shift / reduce step is a step. The current rule highlights. The error recovery, when it happens, is visible — a subtree dies, a new one grows in its place, the resync token is annotated.</p>

<h3 id="ast-viewer-with-types-folded-in">AST Viewer with Types Folded In</h3>

<p>A static view (not animated) of the AST after the parser finishes, with type annotations folded into each node. Hover over a node to see its full type; click to dive into a subtree. The same view, but for the <em>typed</em> AST after the type checker runs, with inferred types added. Then the same view for each lowering pass — AST → CFG → SSA → linearized IR — with arrows showing what produced what.</p>

<h3 id="lowering-and-codegen-side-by-side">Lowering and Codegen Side-by-Side</h3>

<p>Three columns: source, IR, target assembly. Pick a line in any column; the corresponding range highlights in the other two. The lowering passes get their own animation: tail-call elimination shows the call disappearing and the branch appearing; closure conversion shows the free variables being collected and packed; trampolining shows the indirect jump being inserted.</p>

<h3 id="register-allocation-visualized">Register Allocation, Visualized</h3>

<p>The conflict graph. The live ranges. The interference. The spills. Watch the graph-coloring algorithm run, color by color. Watch the spill heuristics pick which range to evict. <em>See</em> what your compiler optimizer is actually doing when it picks <code class="language-plaintext highlighter-rouge">r4</code> instead of <code class="language-plaintext highlighter-rouge">r2</code> for that loop variable.</p>

<h3 id="simple-optimizations-beforeafter">Simple Optimizations Before/After</h3>

<p>Constant folding. Common subexpression elimination. Dead-code elimination. Loop-invariant code motion. Each one as a side-by-side before/after with the moved/removed code highlighted. The whole point of these is that they’re <em>small</em> and <em>understandable</em> if you can see them; they’re black-box magic if you can’t.</p>

<h3 id="heap-and-stack-instrumentation">Heap and Stack Instrumentation</h3>

<p>A live memory map. The stack growing and shrinking with each call/return. The heap filling with allocations, each allocation a colored block. Free / dispose / reclaim animates: the block fades, the free list pointer redirects, the block is gone. The hardest classes of bug — use-after-free, double-free, leaks — become <em>visible</em> the moment the memory map is.</p>

<h3 id="garbage-collectors-at-work">Garbage Collectors at Work</h3>

<p>Mark-and-sweep, copying, generational. Each one a different animation. Mark-and-sweep: a wave of color sweeps the heap from the root set; everything not colored gets reclaimed. Copying: two semi-spaces, the live objects walk from one to the other, the old space is wiped. Generational: nursery / tenured, promotions visible, write-barrier hits flagged. The OCaml interpreter’s incoming GC (<a href="https://github.com/sw-embed/sw-cor24-ocaml/issues/28">sw-cor24-ocaml#28</a>) would be the first candidate — I want to <em>watch</em> it run.</p>

<h3 id="jits-tiering-up">JITs Tiering Up</h3>

<p>A function getting called once: interpreted. Called a thousand times: tier-up triggers, a jitter compiles a baseline native version, the call site rewrites itself, subsequent calls run at native speed. Hit a deopt: tier-down to the interpreter, the native code gets discarded, the next thousand calls retrigger the jit. This is the part of modern runtime engineering that is hardest to <em>see</em>; the visualization that makes it watchable would be the tool I would have wanted as a junior engineer.</p>

<p>The pattern across all of these: the textbook diagrams are static. The visualizer shows them moving. Once you have seen a register allocator color a graph, you cannot read about register allocation the same way again — the diagrams in the textbook map onto something you actually watched happen.</p>

<h2 id="why-now">Why Now</h2>

<p>All three categories above became viable in the same window for the same two reasons — retirement gave the time, AI agents gave the reach. Every one of these projects, on its own, would have been a multi-year team effort five years ago. Today they sit on the list, and the list is moving:</p>

<ul>
  <li>A new paradigm of programming-language <em>surface</em> (DiscoveryOne) is one vertical slice (M7) from being demoable.</li>
  <li>Five language implementations sit between “compiles” and “in production daily use,” each filling a different niche in the small ecosystem.</li>
  <li>The visible-compiler tool category is the unifying frame — the surface I want to have for <em>every</em> compiler I write, including the five above.</li>
</ul>

<p>The next post in the series will probably be about whichever of these turns out to land first. My money is on the lexer / parser visualizer for sw-MLPL, since the language is already runnable and the visualizer is mostly “yew web app” away. We’ll see.</p>

<p>The list keeps growing. The bucket keeps filling. That’s the point.</p>]]></content><author><name>Software Wrighter</name></author><category term="personal" /><category term="projects" /><category term="languages" /><category term="compilers" /><category term="bucket-list" /><category term="programming-languages" /><category term="compilers" /><category term="3d-source" /><category term="visualization" /><category term="education" /><category term="lexer" /><category term="parser" /><category term="ast" /><category term="register-allocation" /><category term="garbage-collector" /><category term="jit" /><category term="plsw" /><category term="sws" /><category term="sw-mlpl" /><category term="tuplet" /><category term="discoveryone" /><category term="wasm" /><category term="cor24" /><summary type="html"><![CDATA[The first two posts in this series listed the things I always wanted to build (part 1) and the surprisingly long list that got crossed off in two weeks of vibe-coding (part 2). Three categories of work currently pulling at me are not yet on either list. They belong on the list. This post adds them. Why this matters — A bucket list is only useful if it grows as fast as it shrinks. Crossing things off without adding things back is how a list gets shorter than the curiosities of the person carrying it. The three categories below are what’s been quietly moving from “interesting” to “I’m actually doing this” since the last post — and they share a thread: each one is something a working engineer rarely gets to do (build a new programming language, sculpt a new authoring surface, or instrument a compiler so you can watch it think) because the day job never gives that kind of room. Retirement and AI agents jointly do. Resource Link DiscoveryOne (3D source) softwarewrighter/DiscoveryOne Tuplet (2D, DiscoveryOne’s predecessor) softwarewrighter/tuplet sw-MLPL (array language) sw-ml-study/sw-mlpl · live REPL PL/SW (PL/I-inspired systems lang) sw-embed/sw-cor24-plsw · live demo SWS (Tcl-like resident shell) sw-embed/sw-cor24-script Bucket List softwarewrighter/bucketlist Prior posts Part 1 · Part 2 Comments Discord Source Code’s Third Dimension Programmers get attached to the medium they happened to learn on. People who started on punch cards remember source as a one-dimensional thing: a stack of cards, fed sequentially, each card eighty columns of fixed-width sequencing, each program a literal physical pile. People who started on glass terminals (which is most of us) think of source as two-dimensional: a window of lines and columns, scrollable in two axes, with the assumption that the meaningful structure lives inside that rectangle. Every editor we use today is still a 2D-rectangle authoring surface. We have learned to interleave many concerns inside that rectangle — the algorithm, the types of inputs and outputs, the preconditions and postconditions the algorithm assumes, the generated form the compiler ends up emitting, the implementation layers (logging, error handling, instrumentation) that production code accumulates. Modern languages provide affordances for hiding most of this — type signatures collapse, comments fold, error handling moves to attributes or decorators — but it all still lives in the same rectangle, fighting for the same screen real estate, and the front-of-the-eye reading order is whatever the editor’s vertical scrollbar decides. Punch cards are 1D. Screens are 2D. What if source were 3D? DiscoveryOne is the project I am sketching to make that question concrete. Every glyph in a DiscoveryOne program has an (x, y, z) coordinate and an aspect label drawn from a fixed set: @front, @left, @right, @top, @bottom, @rear, @internal. A definition is not a block of text; it is a small cube of meaning, and the user views one facet at a time: Facet Role Front Algorithm gist — the readable story Left Inputs (arity, names, types) Right Outputs (arity, names, types) Top Preconditions Bottom Postconditions Rear Generated form (WAT or stack IR) Internal Implementation in spatially-separable layers The Yew/WASM web app loads the file, projects it onto the requested facet, and runs the WASM module in-page when you click Run. A *Power definition reading n e -&gt; p; p &lt;- 1; loop e times: p &lt;- p * n lives on @front; n : Int, e : Int lives on @left; the output type p : Int lives on @right; e &gt;= 0 lives on @top; p == n^e lives on @bottom. Aspects (preconditions, postconditions, tracing, profiling, error recovery) live on spatially separate @internal layers so the front facet stays uncluttered for reading. DiscoveryOne is the successor to Tuplet, my current 2D-layout-sensitive language with first-class named tuples and user-mintable verbs (the * operator literally mints new syntax). Tuplet keeps source 2D but layout-sensitive — where a glyph sits on a 2D grid changes its meaning. DiscoveryOne is the next jump: from “layout matters” to “facet matters.” The same glyph in two different @-aspects participates in two different parts of the program’s meaning. Whether 3D source is useful is a real question. It might turn out that the seven facets are too many, or that the projection UI is too clumsy, or that humans really do read code best as a flat top-to-bottom story. I don’t know. The way to find out is to build it, drive a non-trivial program through it, and see whether reading the front facet of an unfamiliar definition is faster than reading the equivalent flat code with all its types and contracts and tracing inline. DiscoveryOne is currently pre-M0 — the specification is committed; no code yet. The single demoable target (M7 in the saga plan) is one vertical slice: the user authors a *Power definition and a *syntax do _ while _ end expand syntax declaration, and runs both inside the web app. If that slice feels right, the rest follows. Five Languages of My Own, in Flight The other thing on the list right now is making programming languages, plural. Five of mine are currently between “design committed” and “live demo,” each picking a different point on the build/run space. Listed in roughly the order I started them: PL/SW — PL/I-Inspired Systems Language sw-cor24-plsw is a small systems-programming language inspired by PL/I (and a little IBM HLASM). It is the language I use to write higher-level COR24 programs — the SNOBOL4 interpreter, parts of the toolchain, the Fortran compiler in flight. It compiles natively to COR24 assembly and has its own vibe-maintenance heatmap of issues closed in the past few weeks (forty-plus, including the inevitable parade of “AST pool too small,” “MAX_PROCS too small,” “emit buffer too small” capacity bumps). PL/SW’s interesting bet is macros: PL/I-style %DEFINE and MACRODEF GEN blocks that emit assembly. That makes the language usable as a meta-assembler — a higher-level surface that still gives you bit-level control over the COR24 instruction stream. It is the language I would have wanted in 1985 if I had had any say in the matter. SWS — Tcl-Like Resident Shell sw-cor24-script is a tiny Tcl-style scripting language that runs inside the resident monitor on COR24, sharing the program registry. The shell is a single binary that loads at 0x020000 (above the monitor at zero), and its commands operate on whatever programs the monitor has loaded into the slot table. It is the missing surface for a “1980s style” embedded workflow — the user types run hello at a prompt, the monitor’s service vector dispatches into the program at that slot, and control flow returns through a longjmp-style trampoline. SWS exists because every other language in the lab is non-resident — a load happens, a program runs, the run ends, the host runs the next thing. SWS is the language designed for the case where the user is part of the loop: type, run, observe, type again. The repl on a 1 MiB COR24, with a 3 KiB EBR stack, in the year 2026. sw-MLPL — A Rust-First Array Language sw-mlpl is the array-and-tensor programming language I am building for the ML side of the bucket list. The lineage is APL → APL2 → J → BQN, but the implementation is Rust-first: a REPL in the terminal and the browser, a mlpl! proc macro that lets MLPL expressions live inside Rust source, an mlpl build path that compiles MLPL programs to native binaries, and a roadmap of backends (Apple MLX for Apple Silicon, CUDA for distributed training, Ollama / llama.cpp / OpenAI-compatible servers for LLM glue). MLPL is the only one of the five that is mostly built — the live REPL works in the browser today, the language reference is written, the compiler implementation has a tour document for educational reading. What’s still ahead is the long tail of array-language features (rank polymorphism, fork composition, J-style tacit programming) and the big backends. It is the language I will use to do the fine-tune-a-base-model item from part 1. Tuplet — 2D Layout-Sensitive Language with Mintable Verbs tuplet is the language I am driving as my main daily-language experiment. It is 2D-layout-sensitive (where a glyph sits on a grid matters), has first-class named tuples and multi-output verbs, and lets the user mint new verbs and new syntax via the * operator. The kernel is small; everything else — including control flow — is library code expressed in the kernel. The host is an OCaml-subset interpreter; the runtime target is Forth running on the COR24 emulator. Tuplet is currently in the “wakes the dragons” phase of language development — the language compiles, demos run, but the OCaml interpreter underneath has been thrashing its heap until the GC work in flight (sw-cor24-ocaml#28) lands. Once that lands, Tuplet’s heap_limit should shrink, not stay where it is, and the language will start to feel less fragile. DiscoveryOne — 3D Successor Already covered above. DiscoveryOne is to Tuplet what Tuplet is to a flat language: one more dimension of authoring surface, one more affordance for separating concerns spatially rather than syntactically. It is the place where I get to ask the broader question — can authoring be 3D? — without bolting it onto a language whose users are already doing real work. The 1980s Ancestors: Mass Compile and PL/EDIT Two of the threads above have ancestors I worked on as a junior engineer at IBM in the 1980s — Mass Compile, a screen for scheduling batch compile jobs in advance (including jobs for code you had not finished writing), and PL/EDIT, a template-driven editor that expanded triggers into boilerplate via hotkey. Both ran on AQ, an MVS/ESA time-sharing service. The Throwback Thursday post tells the story in detail. The reason they come up here is the conceptual lineage: Mass Compile’s “do the expensive thing against whatever inputs are stable when it runs” is the same shape as sw-launcher’s content-addressed cache, and PL/EDIT’s “fill in the slot that matters and let the template handle the rest” is the same shape as DiscoveryOne’s facet authoring. Both ideas keep coming back, in different shapes, every time the dev loop gets a new bottleneck. I maintained both tools; I did not write them. That was the right level for me at the time, and the vibe-maintenance post reprises the lesson forty years later: a tool you maintain long enough teaches you the design choices its author made and the seams where the next idea wants to break in. The bucket list, on this reading, is partly a list of the bottlenecks I have seen over the years and the surfaces I want to build to push back on them. Visible Compilers — The Missing Tool Category The unifying gap, behind all of the above, is visibility. Almost every CS curriculum spends a semester on compilers. Almost every working engineer uses one every day. Almost no engineer has ever watched a compiler run — watched the lexer turn a stream of characters into tokens, watched the parser grow an AST, watched a type checker fail and recover, watched a register allocator color a graph, watched a heap fill up and a GC reclaim it. The mechanism is invisible. We read about it. We trust the diagrams in textbooks. We never see the diagram move. The next chunk of the bucket list is the tooling that fixes that. For each of the five languages above (and ideally for the COR24 toolchain in general), I want a visible counterpart: Step-Through Lexer A panel that shows the source on the left and the token stream on the right. Click “step” — the cursor advances by one token, the new token glows in the right panel, the consumed characters fade in the left panel. Speed it up to “auto” and the whole stream animates past at one-token-per-50ms. See the lexer. Animated Parser The same idea for the parser: source on the left, the AST growing on the right as a tree. Each shift / reduce step is a step. The current rule highlights. The error recovery, when it happens, is visible — a subtree dies, a new one grows in its place, the resync token is annotated. AST Viewer with Types Folded In A static view (not animated) of the AST after the parser finishes, with type annotations folded into each node. Hover over a node to see its full type; click to dive into a subtree. The same view, but for the typed AST after the type checker runs, with inferred types added. Then the same view for each lowering pass — AST → CFG → SSA → linearized IR — with arrows showing what produced what. Lowering and Codegen Side-by-Side Three columns: source, IR, target assembly. Pick a line in any column; the corresponding range highlights in the other two. The lowering passes get their own animation: tail-call elimination shows the call disappearing and the branch appearing; closure conversion shows the free variables being collected and packed; trampolining shows the indirect jump being inserted. Register Allocation, Visualized The conflict graph. The live ranges. The interference. The spills. Watch the graph-coloring algorithm run, color by color. Watch the spill heuristics pick which range to evict. See what your compiler optimizer is actually doing when it picks r4 instead of r2 for that loop variable. Simple Optimizations Before/After Constant folding. Common subexpression elimination. Dead-code elimination. Loop-invariant code motion. Each one as a side-by-side before/after with the moved/removed code highlighted. The whole point of these is that they’re small and understandable if you can see them; they’re black-box magic if you can’t. Heap and Stack Instrumentation A live memory map. The stack growing and shrinking with each call/return. The heap filling with allocations, each allocation a colored block. Free / dispose / reclaim animates: the block fades, the free list pointer redirects, the block is gone. The hardest classes of bug — use-after-free, double-free, leaks — become visible the moment the memory map is. Garbage Collectors at Work Mark-and-sweep, copying, generational. Each one a different animation. Mark-and-sweep: a wave of color sweeps the heap from the root set; everything not colored gets reclaimed. Copying: two semi-spaces, the live objects walk from one to the other, the old space is wiped. Generational: nursery / tenured, promotions visible, write-barrier hits flagged. The OCaml interpreter’s incoming GC (sw-cor24-ocaml#28) would be the first candidate — I want to watch it run. JITs Tiering Up A function getting called once: interpreted. Called a thousand times: tier-up triggers, a jitter compiles a baseline native version, the call site rewrites itself, subsequent calls run at native speed. Hit a deopt: tier-down to the interpreter, the native code gets discarded, the next thousand calls retrigger the jit. This is the part of modern runtime engineering that is hardest to see; the visualization that makes it watchable would be the tool I would have wanted as a junior engineer. The pattern across all of these: the textbook diagrams are static. The visualizer shows them moving. Once you have seen a register allocator color a graph, you cannot read about register allocation the same way again — the diagrams in the textbook map onto something you actually watched happen. Why Now All three categories above became viable in the same window for the same two reasons — retirement gave the time, AI agents gave the reach. Every one of these projects, on its own, would have been a multi-year team effort five years ago. Today they sit on the list, and the list is moving: A new paradigm of programming-language surface (DiscoveryOne) is one vertical slice (M7) from being demoable. Five language implementations sit between “compiles” and “in production daily use,” each filling a different niche in the small ecosystem. The visible-compiler tool category is the unifying frame — the surface I want to have for every compiler I write, including the five above. The next post in the series will probably be about whichever of these turns out to land first. My money is on the lexer / parser visualizer for sw-MLPL, since the language is already runnable and the visualizer is mostly “yew web app” away. We’ll see. The list keeps growing. The bucket keeps filling. That’s the point.]]></summary></entry><entry><title type="html">Personal Software #9: Vibe-Maintenance — When AI Agents Don’t Just Write Code, They Fix Bugs</title><link href="https://blog.softwarewrighter.com/2026/04/29/personal-software-vibe-maintenance/" rel="alternate" type="text/html" title="Personal Software #9: Vibe-Maintenance — When AI Agents Don’t Just Write Code, They Fix Bugs" /><published>2026-04-29T12:00:00-07:00</published><updated>2026-04-29T12:00:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/04/29/personal-software-vibe-maintenance</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/04/29/personal-software-vibe-maintenance/"><![CDATA[<p><img src="/assets/images/posts/block-car-lift.webp" class="post-marker no-invert" alt="" style="width: 240px;" /></p>

<div style="overflow: hidden;">

  <p>The car is up on the lift. The mechanic is not building a new car; the mechanic is doing maintenance on a car that already runs — replacing a sensor, chasing a leak, tightening a fitting that worked itself loose. The everyday work of a software lab looks more like that picture than like the more common rendering of “AI builds the thing” — a lab full of in-progress compilers, runtimes, and demos accumulates bugs faster than it accumulates <em>features</em>, and the AI agents that helped write the original code are now spending most of their tokens fixing it.</p>

</div>

<div class="aside-box">

  <p><strong>Why this matters</strong> — “Vibe-coding” gets the headlines because it produces something visible: a new feature, a new demo, a new tool. Vibe-maintenance is the quieter half. It does not show up as a flashy commit message; it shows up as a green “Try it” badge that used to say “In dev,” or as a closed-issues heatmap that is busier than the commits one. If your only frame for AI-assisted development is “the agent writes new code,” you miss the half of the work where the agent is reading existing code, finding the assumption that was wrong, and patching it. Almost every senior engineer who has tried to use AI agents finds maintenance more useful than greenfield work; the post argues for why, and what the human still has to do.</p>

</div>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Status dashboard</strong></td>
        <td><a href="https://sw-embed.github.io/web-sw-cor24-demos/#/status">sw-embed.github.io/web-sw-cor24-demos/#/status</a></td>
      </tr>
      <tr>
        <td><strong>Demos repo</strong></td>
        <td><a href="https://github.com/sw-embed/web-sw-cor24-demos">sw-embed/web-sw-cor24-demos</a></td>
      </tr>
      <tr>
        <td><strong>Closed-issues report (raw)</strong></td>
        <td><a href="https://github.com/sw-embed/web-sw-cor24-demos/blob/main/reports/closed-issues.html">reports/closed-issues.html</a></td>
      </tr>
      <tr>
        <td><strong>Prior Personal Software post</strong></td>
        <td><a href="/2026/04/28/personal-software-sw-launcher-one-ring/">Personal Software #8: sw-launcher — One Ring to Rule Them All</a></td>
      </tr>
      <tr>
        <td><strong>Related AI Tools post</strong></td>
        <td><a href="/2026/04/27/sw-checklist-ratchet-ai-coding-agents/">AI Tools #3: sw-checklist — Reining In AI Coding Agents</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="the-corollary-to-vibe-coding">The Corollary to Vibe-Coding</h2>

<p>Vibe-coding, as the term gets used: a human describes intent, an AI agent writes the code, the human accepts or redirects. The implicit assumption is that <em>new code</em> is the bottleneck. For a greenfield project, sure — there is nothing to maintain because there is nothing to maintain <em>yet</em>. For a lab whose accumulated output now spans 37 repositories — assemblers, emulators, cross-compilers, p-code VM, native interpreters for BASIC and Forth and APL and Smalltalk and Macrolisp and SNOBOL4, host tooling, the resident-shell trio, web demos for most of them — the bottleneck moved a long time ago. New code lands; new code interacts with old code; old code that was fine on its own now has a corner that nobody exercised; an issue gets opened.</p>

<p>Vibe-maintenance is the same loop with a different verb. The human writes an issue title. The agent reads the relevant code, locates the assumption that was wrong, makes a minimal patch, adds a regression test, and closes the issue. The human’s job is not to write the code; it is <em>to write the issue</em> with enough specificity that the agent has somewhere to start, and to read the diff with enough care that the test does not just pin in the bug under a different name.</p>

<p>The skill the human keeps is not “writing code.” It is <em>symptom description</em>.</p>

<h2 id="the-status-tab-as-visualization">The Status Tab as Visualization</h2>

<p><a href="https://github.com/sw-embed/web-sw-cor24-demos">web-sw-cor24-demos</a> is the landing page for the COR24 ecosystem. Its Status tab makes the lab’s <em>operational</em> state visible at a glance:</p>

<ul>
  <li>A 37-row table of every repo with a colored badge — “Try it” (green), “In dev” (yellow), “In plan” (orange), “Future” (red), “n/a” (neutral) — for repo readiness, web-UI readiness, and AgentRail saga presence.</li>
  <li>An issue chart per repo: open vs. closed counts and a sparkline.</li>
  <li>A <strong>Closed Issues by Repo &amp; Date</strong> heatmap, generated by <code class="language-plaintext highlighter-rouge">scripts/gen-closed-issues.sh</code> from the GitHub API.</li>
  <li>A <strong>Commits by Repo &amp; Hour</strong> heatmap, the same shape one row down.</li>
  <li>A “Gaps” panel calling out the cross-cutting work the lab has <em>not</em> yet done (software floating-point library, native COR24 C compiler, etc.).</li>
</ul>

<p>The headline number on the closed-issues chart, at the time of writing: <strong>28 repos, 141 issues, 18 days</strong>. Eight closed issues a day, sustained, across two dozen active repos. That is not an output a single human writing code by hand achieves. It is also not an output a single human <em>reviewing AI-written code</em> achieves if every issue requires a fresh greenfield design — it is only achievable because most of those 141 issues were <em>bugs in code that already existed</em>, and an agent can fix one of those in a fraction of the time it took to write the original.</p>

<p>The two heatmaps next to each other tell a story: the commits chart shows when work happened (clusters in the morning, fewer at night, weekend bursts when an idea hit); the closed-issues chart shows what work <em>settled</em> (each cell is a problem that has a regression test guarding it). The cells are mostly numbered links, so any cell on the chart is one click from a real PR diff. That traceability is the whole point.</p>

<h2 id="four-patterns-of-vibe-maintenance">Four Patterns of Vibe-Maintenance</h2>

<p>Reading down the closed-issues report by category — not by repo — four kinds of fix dominate.</p>

<h3 id="1-capacity-limit-bumps">1. Capacity-Limit Bumps</h3>

<p>The numerically-largest category. A compiler or interpreter has an internal table whose size was <em>guessed</em> at first commit (<code class="language-plaintext highlighter-rouge">MAX_PROCS = 32</code>, <code class="language-plaintext highlighter-rouge">INPUT_BUF_SIZE = 8192</code>, AST pool of 256 nodes, emit buffer of 32 KiB, string literal table of 32 entries). A real program hits the limit, the agent bumps it, and a follow-up issue raises it again later when an even bigger program lands.</p>

<p>A small sample, all closed in this window, all from <code class="language-plaintext highlighter-rouge">sw-cor24-plsw</code> and <code class="language-plaintext highlighter-rouge">sw-cor24-pascal</code>:</p>

<ul>
  <li>“AST pool exhaustion (256 nodes) causes misleading parse errors.”</li>
  <li>“Source buffer (8 KiB) too small for larger programs.”</li>
  <li>“Emit buffer (32 KiB) too small for programs with large static data.”</li>
  <li>“DEF_MAX (32) too small — %DEFINE silently dropped when exceeded.”</li>
  <li>“Global symbol table limited to 64 entries (SYM_SCOPE_MAX).”</li>
  <li>“Raise MAX_PROCS from 32 to support larger programs.”</li>
  <li>“Raise MAX_STRINGS limit from 16 to support larger programs.”</li>
  <li>“Raise INPUT_BUF_SIZE from 32768 to support larger programs (third bump).”</li>
</ul>

<p>Each one is a one-line const change plus a regression test that compiles a representative-sized input. The third-bump issue is the funny one: the limit is no longer a guess, it is a parameter that grows with the corpus. Eventually the right answer is “the table grows dynamically,” but the lab’s working assumption is that bumping a static limit is a one-cycle fix, while a dynamic table is a one-day refactor that earns its keep only after the same limit has been bumped enough times to justify it. The ratchet is the right tool for this kind of debt.</p>

<h3 id="2-subtle-codegen-bugs">2. Subtle Codegen Bugs</h3>

<p>The most interesting category. These are not “the feature is missing”; these are “the feature is silently wrong.” A few from the same window:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">sw-cor24-plsw#8</code>: “BYTE field reads use signed <code class="language-plaintext highlighter-rouge">lb</code> instead of unsigned <code class="language-plaintext highlighter-rouge">lbu</code>.” A one-instruction error; values 128–255 sign-extend to negative integers, breaking pattern matching and arithmetic.</li>
  <li><code class="language-plaintext highlighter-rouge">sw-cor24-plsw#31</code>: “Function return corrupts r1 -&gt; jmp to PC=0 (programs that call PUT_DEC re-enter <code class="language-plaintext highlighter-rouge">_start</code>).” A clobbered callee-saved register; the symptom looks like an infinite loop with the program re-running from the top.</li>
  <li><code class="language-plaintext highlighter-rouge">sw-cor24-pcode#10</code>: “p24-load: patch_code_relocations incorrectly relocates negative push literals.” A pointer-vs-immediate confusion in the linker; small negative integers get rewritten as garbage addresses.</li>
  <li><code class="language-plaintext highlighter-rouge">sw-cor24-x-tinyc#19</code>: “Codegen: integer division with negative dividend returns wrong result.” The C cross-compiler’s sign-handling.</li>
  <li><code class="language-plaintext highlighter-rouge">sw-cor24-snobol4#13</code>: “Arithmetic on a pattern-captured string returns garbage on first use.” A type-tag bug in the SNOBOL4 interpreter’s value union.</li>
  <li><code class="language-plaintext highlighter-rouge">sw-cor24-pascal#16</code>: “<code class="language-plaintext highlighter-rouge">write(chr(n))</code> outputs integer <code class="language-plaintext highlighter-rouge">n</code> instead of character.” Built-in dispatch on the wrong type.</li>
  <li><code class="language-plaintext highlighter-rouge">sw-cor24-basic#1</code>: “ABS function silently returns wrong value (parsed as variable A).” The lexer treats <code class="language-plaintext highlighter-rouge">ABS</code> as a variable rather than a builtin, so <code class="language-plaintext highlighter-rouge">ABS(-3)</code> parses as <code class="language-plaintext highlighter-rouge">(A) * BS * (-3)</code> — a beautifully evil bug whose title carries the entire diagnosis.</li>
</ul>

<p>These are the issues where vibe-maintenance shines. Each title is a <em>complete reproducer</em> in plain English. The agent reads the title, opens the relevant translation unit, finds the wrong instruction or the wrong dispatch, fixes it, writes a one-program regression test, and closes the issue. The human never wrote a line of code; the human wrote a <em>fifteen-word symptom description</em>.</p>

<h3 id="3-surface-language-features-rolling-in">3. Surface Language Features Rolling In</h3>

<p>Each language interpreter ships with a minimum viable feature set, and demos that exercise more of the historical language drag in features that didn’t make the first cut:</p>

<ul>
  <li><strong>BASIC</strong>: DIM integer arrays, DATA / READ / RESTORE, ON expr GOTO/GOSUB, MOD, bitwise BAND/BOR/BXOR/SHL/SHR, CONT after STOP. (<code class="language-plaintext highlighter-rouge">sw-cor24-basic#2..#7</code>.)</li>
  <li><strong>OCaml</strong>: top-level let bindings, multi-line match expressions, mutable refs, records, list combinators (<code class="language-plaintext highlighter-rouge">map</code>/<code class="language-plaintext highlighter-rouge">fold_left</code>/<code class="language-plaintext highlighter-rouge">filter</code>), char literals, block comments, exceptions. (<code class="language-plaintext highlighter-rouge">sw-cor24-ocaml#3..#11</code>.)</li>
  <li><strong>Forth</strong>: forth-in-forth gets DO/LOOP, ?DO, WHILE/REPEAT, AGAIN, CONSTANT, VARIABLE, hashed dictionary; <code class="language-plaintext highlighter-rouge">:NONAME</code>. (<code class="language-plaintext highlighter-rouge">sw-cor24-forth#1..#5</code>.)</li>
  <li><strong>SNOBOL4</strong>: SIZE/SUBSTR/CHAR builtins, pattern-replacement assignment, case-preserving INPUT mode. (<code class="language-plaintext highlighter-rouge">sw-cor24-snobol4#1..#10</code>.)</li>
  <li><strong>TinyC</strong>: goto + labels, compound literals, designated initializers, <code class="language-plaintext highlighter-rouge">_Noreturn</code>, <code class="language-plaintext highlighter-rouge">restrict</code>, <code class="language-plaintext highlighter-rouge">inline</code>, octal literals, multi-dimensional array declarations. (<code class="language-plaintext highlighter-rouge">sw-cor24-x-tinyc#2..#11</code>.)</li>
</ul>

<p>Each of these is the kind of feature that <em>would</em> take a human a half-day if they had to read the existing parser, find the right place to extend it, and add the right test fixtures. Vibe-coding compresses that to a half-hour of agent work plus a human-written acceptance criterion. The acceptance criterion is the part that does not get cheaper.</p>

<h3 id="4-cross-cutting-tooling-bugs">4. Cross-Cutting Tooling Bugs</h3>

<p>The last category is the meta one: the tooling that <em>generates</em> the dashboards itself has bugs. A representative pair from the demos repo’s commit log, just in the past two weeks:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">fix UTC-to-local date conversion in gen-issue-chart, rebuild and deploy pages</code></li>
  <li><code class="language-plaintext highlighter-rouge">fix timezone in activity reports: convert UTC dates/hours to local, regenerate tables</code></li>
</ul>

<p>Two separate “use Local::now() instead of Utc::now()” commits in two different generators. The cells in the heatmap were shifted by a few hours, which made yesterday’s work look like today’s, which made the dashboard wrong — subtly, in a way that would only catch the eye of someone who <em>knew</em> what they had committed yesterday. The agent fixed both. They are listed in the dashboard the agent generated. The fact that the dashboard works is itself a regression test on its own generators.</p>

<h2 id="the-skill-that-doesnt-go-away">The Skill That Doesn’t Go Away</h2>

<p>Every category above leans on the same human contribution: a <em>precise</em> description of the symptom, often as the issue title.</p>

<p>Compare the two SNOBOL4 issues:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">#1</code>: “Missing string builtins: SIZE, SUBSTR, CHAR return 0 for all inputs.”</li>
  <li><code class="language-plaintext highlighter-rouge">#11</code>: “OUTPUT corruption: concat-OUTPUT in a loop truncates when a different block declares a pattern-match with <code class="language-plaintext highlighter-rouge">:F(forward_label)</code>.”</li>
</ul>

<p>Both are real titles. Both are diagnoses, not just symptoms — they tell the agent <em>exactly</em> what subsystem to read. <code class="language-plaintext highlighter-rouge">#1</code> is mechanical: open the builtins table, see that the entries return 0, fix them. <code class="language-plaintext highlighter-rouge">#11</code> is forensic: the title names the loop, the operator, the block, the directive, and the conditional — the agent has the entire reproducer one paste away from a test fixture.</p>

<p>The bad version of either title would be “OUTPUT is broken.” That title costs the agent half its budget on guessing what “broken” means and produces a fix that may or may not address the real bug.</p>

<p>The skill the lab keeps practicing is <em>writing the issue at the level of detail the agent needs</em>. That is roughly the same skill an engineer uses to file a useful bug report against another engineer. The difference is that the audience is faster and cheaper than the engineer; the title is read inside a second, the diff is back inside a minute, and the regression test is attached. The economics of writing good issues, in a vibe-maintenance world, are vastly more favorable than they were when the audience was a human queue with their own backlog.</p>

<h2 id="the-feedback-loop">The Feedback Loop</h2>

<div class="gutter-section">

  <p><img src="/assets/images/posts/block-mobius-strip.webp" class="gutter-img-left no-invert" alt="Möbius strip" /></p>

  <p>The maintenance loop, as it actually runs in this lab, has five steps:</p>

  <ol>
    <li><strong>Symptom.</strong> A demo, test, or build fails. A user reports a wrong output. A regression test caught a regression. CI flagged a gate.</li>
    <li><strong>Issue.</strong> The human (or another agent) writes a one-line title and a short body that names the conditions. Most of the time the title is enough.</li>
    <li><strong>AgentRail saga step.</strong> For non-trivial fixes, the work goes onto an <a href="/2026/04/28/ai-tools-agentrail-rs-mid-saga-flexibility/">AgentRail</a> step — a single session does the diff, commits, and runs <code class="language-plaintext highlighter-rouge">agentrail complete</code>. The session is bounded; the next session is for the next step.</li>
    <li><strong>Regression test.</strong> The diff includes a test that pins the bug fixed. <code class="language-plaintext highlighter-rouge">cargo test</code> / <code class="language-plaintext highlighter-rouge">make test</code> is the contract. If a future change re-introduces the bug, the test catches it.</li>
    <li><strong>Status refresh.</strong> <code class="language-plaintext highlighter-rouge">cargo run -p gen-status</code> and the closed-issues / commits scripts re-pull from the GitHub API; the heatmap moves; the badge in the table tilts greener.</li>
  </ol>

  <p>This is not a novel workflow — it is what every well-run engineering team does. What is <em>new</em> is that the per-issue cost is small enough that the heatmap is <em>busy</em>. Eight issues a day across a constellation of personal projects, sustained for weeks, is the kind of cadence that used to require a small team. One human plus AI agents plus the discipline of writing good issues hits it.</p>

  <p>The artifact, in the end, is not “the AI fixed 141 bugs.” The artifact is the dashboard — 28 repos getting visibly greener, with each cell on the heatmap a clickable link to the diff that closed it. The lab is <em>legible</em>, and being legible makes it possible to do the work at this pace in the first place.</p>

</div>

<h2 id="where-it-sits-in-the-personal-software-toolkit">Where It Sits in the Personal-Software Toolkit</h2>

<p><a href="/2026/04/27/sw-checklist-ratchet-ai-coding-agents/">sw-checklist</a> keeps the <em>shape</em> of the code in line. <a href="/2026/04/28/personal-software-sw-launcher-one-ring/">sw-launcher</a> keeps the <em>shape of the load plan and memory budget</em> in line. <a href="/2026/04/23/ai-tools-agentrail-rs-mid-saga-flexibility/">AgentRail</a> keeps the <em>shape of the work</em> in line — one saga, one step at a time, with a faithful audit trail. The Status tab is the <em>operational dashboard</em> that makes the result of all three legible at a glance. None of those tools, individually, would be enough to keep a 37-repo lab maintainable by one person; together they make vibe-maintenance the steady-state mode of operation.</p>

<p>The car is up on the lift. The mechanic is not building anything new today. The mechanic is going around with a torque wrench, an oil drain, and a parts list, and at the end of the afternoon every gauge is in the green again. AI agents do not change which afternoons that work happens on. They change how many cars fit in the shop.</p>]]></content><author><name>Software Wrighter</name></author><category term="ai-tools" /><category term="cli-tools" /><category term="embedded" /><category term="vibe-maintenance" /><category term="vibe-coding" /><category term="personal-software" /><category term="ai-coding" /><category term="ai-agents" /><category term="cor24" /><category term="sw-embed" /><category term="bug-fixes" /><category term="regression-tests" /><category term="agentrail" /><category term="status-dashboard" /><summary type="html"><![CDATA[The car is up on the lift. The mechanic is not building a new car; the mechanic is doing maintenance on a car that already runs — replacing a sensor, chasing a leak, tightening a fitting that worked itself loose. The everyday work of a software lab looks more like that picture than like the more common rendering of “AI builds the thing” — a lab full of in-progress compilers, runtimes, and demos accumulates bugs faster than it accumulates features, and the AI agents that helped write the original code are now spending most of their tokens fixing it. Why this matters — “Vibe-coding” gets the headlines because it produces something visible: a new feature, a new demo, a new tool. Vibe-maintenance is the quieter half. It does not show up as a flashy commit message; it shows up as a green “Try it” badge that used to say “In dev,” or as a closed-issues heatmap that is busier than the commits one. If your only frame for AI-assisted development is “the agent writes new code,” you miss the half of the work where the agent is reading existing code, finding the assumption that was wrong, and patching it. Almost every senior engineer who has tried to use AI agents finds maintenance more useful than greenfield work; the post argues for why, and what the human still has to do. Resource Link Status dashboard sw-embed.github.io/web-sw-cor24-demos/#/status Demos repo sw-embed/web-sw-cor24-demos Closed-issues report (raw) reports/closed-issues.html Prior Personal Software post Personal Software #8: sw-launcher — One Ring to Rule Them All Related AI Tools post AI Tools #3: sw-checklist — Reining In AI Coding Agents Comments Discord The Corollary to Vibe-Coding Vibe-coding, as the term gets used: a human describes intent, an AI agent writes the code, the human accepts or redirects. The implicit assumption is that new code is the bottleneck. For a greenfield project, sure — there is nothing to maintain because there is nothing to maintain yet. For a lab whose accumulated output now spans 37 repositories — assemblers, emulators, cross-compilers, p-code VM, native interpreters for BASIC and Forth and APL and Smalltalk and Macrolisp and SNOBOL4, host tooling, the resident-shell trio, web demos for most of them — the bottleneck moved a long time ago. New code lands; new code interacts with old code; old code that was fine on its own now has a corner that nobody exercised; an issue gets opened. Vibe-maintenance is the same loop with a different verb. The human writes an issue title. The agent reads the relevant code, locates the assumption that was wrong, makes a minimal patch, adds a regression test, and closes the issue. The human’s job is not to write the code; it is to write the issue with enough specificity that the agent has somewhere to start, and to read the diff with enough care that the test does not just pin in the bug under a different name. The skill the human keeps is not “writing code.” It is symptom description. The Status Tab as Visualization web-sw-cor24-demos is the landing page for the COR24 ecosystem. Its Status tab makes the lab’s operational state visible at a glance: A 37-row table of every repo with a colored badge — “Try it” (green), “In dev” (yellow), “In plan” (orange), “Future” (red), “n/a” (neutral) — for repo readiness, web-UI readiness, and AgentRail saga presence. An issue chart per repo: open vs. closed counts and a sparkline. A Closed Issues by Repo &amp; Date heatmap, generated by scripts/gen-closed-issues.sh from the GitHub API. A Commits by Repo &amp; Hour heatmap, the same shape one row down. A “Gaps” panel calling out the cross-cutting work the lab has not yet done (software floating-point library, native COR24 C compiler, etc.). The headline number on the closed-issues chart, at the time of writing: 28 repos, 141 issues, 18 days. Eight closed issues a day, sustained, across two dozen active repos. That is not an output a single human writing code by hand achieves. It is also not an output a single human reviewing AI-written code achieves if every issue requires a fresh greenfield design — it is only achievable because most of those 141 issues were bugs in code that already existed, and an agent can fix one of those in a fraction of the time it took to write the original. The two heatmaps next to each other tell a story: the commits chart shows when work happened (clusters in the morning, fewer at night, weekend bursts when an idea hit); the closed-issues chart shows what work settled (each cell is a problem that has a regression test guarding it). The cells are mostly numbered links, so any cell on the chart is one click from a real PR diff. That traceability is the whole point. Four Patterns of Vibe-Maintenance Reading down the closed-issues report by category — not by repo — four kinds of fix dominate. 1. Capacity-Limit Bumps The numerically-largest category. A compiler or interpreter has an internal table whose size was guessed at first commit (MAX_PROCS = 32, INPUT_BUF_SIZE = 8192, AST pool of 256 nodes, emit buffer of 32 KiB, string literal table of 32 entries). A real program hits the limit, the agent bumps it, and a follow-up issue raises it again later when an even bigger program lands. A small sample, all closed in this window, all from sw-cor24-plsw and sw-cor24-pascal: “AST pool exhaustion (256 nodes) causes misleading parse errors.” “Source buffer (8 KiB) too small for larger programs.” “Emit buffer (32 KiB) too small for programs with large static data.” “DEF_MAX (32) too small — %DEFINE silently dropped when exceeded.” “Global symbol table limited to 64 entries (SYM_SCOPE_MAX).” “Raise MAX_PROCS from 32 to support larger programs.” “Raise MAX_STRINGS limit from 16 to support larger programs.” “Raise INPUT_BUF_SIZE from 32768 to support larger programs (third bump).” Each one is a one-line const change plus a regression test that compiles a representative-sized input. The third-bump issue is the funny one: the limit is no longer a guess, it is a parameter that grows with the corpus. Eventually the right answer is “the table grows dynamically,” but the lab’s working assumption is that bumping a static limit is a one-cycle fix, while a dynamic table is a one-day refactor that earns its keep only after the same limit has been bumped enough times to justify it. The ratchet is the right tool for this kind of debt. 2. Subtle Codegen Bugs The most interesting category. These are not “the feature is missing”; these are “the feature is silently wrong.” A few from the same window: sw-cor24-plsw#8: “BYTE field reads use signed lb instead of unsigned lbu.” A one-instruction error; values 128–255 sign-extend to negative integers, breaking pattern matching and arithmetic. sw-cor24-plsw#31: “Function return corrupts r1 -&gt; jmp to PC=0 (programs that call PUT_DEC re-enter _start).” A clobbered callee-saved register; the symptom looks like an infinite loop with the program re-running from the top. sw-cor24-pcode#10: “p24-load: patch_code_relocations incorrectly relocates negative push literals.” A pointer-vs-immediate confusion in the linker; small negative integers get rewritten as garbage addresses. sw-cor24-x-tinyc#19: “Codegen: integer division with negative dividend returns wrong result.” The C cross-compiler’s sign-handling. sw-cor24-snobol4#13: “Arithmetic on a pattern-captured string returns garbage on first use.” A type-tag bug in the SNOBOL4 interpreter’s value union. sw-cor24-pascal#16: “write(chr(n)) outputs integer n instead of character.” Built-in dispatch on the wrong type. sw-cor24-basic#1: “ABS function silently returns wrong value (parsed as variable A).” The lexer treats ABS as a variable rather than a builtin, so ABS(-3) parses as (A) * BS * (-3) — a beautifully evil bug whose title carries the entire diagnosis. These are the issues where vibe-maintenance shines. Each title is a complete reproducer in plain English. The agent reads the title, opens the relevant translation unit, finds the wrong instruction or the wrong dispatch, fixes it, writes a one-program regression test, and closes the issue. The human never wrote a line of code; the human wrote a fifteen-word symptom description. 3. Surface Language Features Rolling In Each language interpreter ships with a minimum viable feature set, and demos that exercise more of the historical language drag in features that didn’t make the first cut: BASIC: DIM integer arrays, DATA / READ / RESTORE, ON expr GOTO/GOSUB, MOD, bitwise BAND/BOR/BXOR/SHL/SHR, CONT after STOP. (sw-cor24-basic#2..#7.) OCaml: top-level let bindings, multi-line match expressions, mutable refs, records, list combinators (map/fold_left/filter), char literals, block comments, exceptions. (sw-cor24-ocaml#3..#11.) Forth: forth-in-forth gets DO/LOOP, ?DO, WHILE/REPEAT, AGAIN, CONSTANT, VARIABLE, hashed dictionary; :NONAME. (sw-cor24-forth#1..#5.) SNOBOL4: SIZE/SUBSTR/CHAR builtins, pattern-replacement assignment, case-preserving INPUT mode. (sw-cor24-snobol4#1..#10.) TinyC: goto + labels, compound literals, designated initializers, _Noreturn, restrict, inline, octal literals, multi-dimensional array declarations. (sw-cor24-x-tinyc#2..#11.) Each of these is the kind of feature that would take a human a half-day if they had to read the existing parser, find the right place to extend it, and add the right test fixtures. Vibe-coding compresses that to a half-hour of agent work plus a human-written acceptance criterion. The acceptance criterion is the part that does not get cheaper. 4. Cross-Cutting Tooling Bugs The last category is the meta one: the tooling that generates the dashboards itself has bugs. A representative pair from the demos repo’s commit log, just in the past two weeks: fix UTC-to-local date conversion in gen-issue-chart, rebuild and deploy pages fix timezone in activity reports: convert UTC dates/hours to local, regenerate tables Two separate “use Local::now() instead of Utc::now()” commits in two different generators. The cells in the heatmap were shifted by a few hours, which made yesterday’s work look like today’s, which made the dashboard wrong — subtly, in a way that would only catch the eye of someone who knew what they had committed yesterday. The agent fixed both. They are listed in the dashboard the agent generated. The fact that the dashboard works is itself a regression test on its own generators. The Skill That Doesn’t Go Away Every category above leans on the same human contribution: a precise description of the symptom, often as the issue title. Compare the two SNOBOL4 issues: #1: “Missing string builtins: SIZE, SUBSTR, CHAR return 0 for all inputs.” #11: “OUTPUT corruption: concat-OUTPUT in a loop truncates when a different block declares a pattern-match with :F(forward_label).” Both are real titles. Both are diagnoses, not just symptoms — they tell the agent exactly what subsystem to read. #1 is mechanical: open the builtins table, see that the entries return 0, fix them. #11 is forensic: the title names the loop, the operator, the block, the directive, and the conditional — the agent has the entire reproducer one paste away from a test fixture. The bad version of either title would be “OUTPUT is broken.” That title costs the agent half its budget on guessing what “broken” means and produces a fix that may or may not address the real bug. The skill the lab keeps practicing is writing the issue at the level of detail the agent needs. That is roughly the same skill an engineer uses to file a useful bug report against another engineer. The difference is that the audience is faster and cheaper than the engineer; the title is read inside a second, the diff is back inside a minute, and the regression test is attached. The economics of writing good issues, in a vibe-maintenance world, are vastly more favorable than they were when the audience was a human queue with their own backlog. The Feedback Loop The maintenance loop, as it actually runs in this lab, has five steps: Symptom. A demo, test, or build fails. A user reports a wrong output. A regression test caught a regression. CI flagged a gate. Issue. The human (or another agent) writes a one-line title and a short body that names the conditions. Most of the time the title is enough. AgentRail saga step. For non-trivial fixes, the work goes onto an AgentRail step — a single session does the diff, commits, and runs agentrail complete. The session is bounded; the next session is for the next step. Regression test. The diff includes a test that pins the bug fixed. cargo test / make test is the contract. If a future change re-introduces the bug, the test catches it. Status refresh. cargo run -p gen-status and the closed-issues / commits scripts re-pull from the GitHub API; the heatmap moves; the badge in the table tilts greener. This is not a novel workflow — it is what every well-run engineering team does. What is new is that the per-issue cost is small enough that the heatmap is busy. Eight issues a day across a constellation of personal projects, sustained for weeks, is the kind of cadence that used to require a small team. One human plus AI agents plus the discipline of writing good issues hits it. The artifact, in the end, is not “the AI fixed 141 bugs.” The artifact is the dashboard — 28 repos getting visibly greener, with each cell on the heatmap a clickable link to the diff that closed it. The lab is legible, and being legible makes it possible to do the work at this pace in the first place. Where It Sits in the Personal-Software Toolkit sw-checklist keeps the shape of the code in line. sw-launcher keeps the shape of the load plan and memory budget in line. AgentRail keeps the shape of the work in line — one saga, one step at a time, with a faithful audit trail. The Status tab is the operational dashboard that makes the result of all three legible at a glance. None of those tools, individually, would be enough to keep a 37-repo lab maintainable by one person; together they make vibe-maintenance the steady-state mode of operation. The car is up on the lift. The mechanic is not building anything new today. The mechanic is going around with a torque wrench, an oil drain, and a parts list, and at the end of the afternoon every gauge is in the green again. AI agents do not change which afternoons that work happens on. They change how many cars fit in the shop.]]></summary></entry><entry><title type="html">Personal Software #8: One Ring to Rule Them All — sw-launcher’s Memory Profiles, Heap Budgets, and a Working Scenario A</title><link href="https://blog.softwarewrighter.com/2026/04/28/personal-software-sw-launcher-one-ring/" rel="alternate" type="text/html" title="Personal Software #8: One Ring to Rule Them All — sw-launcher’s Memory Profiles, Heap Budgets, and a Working Scenario A" /><published>2026-04-28T12:00:00-07:00</published><updated>2026-04-28T12:00:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/04/28/personal-software-sw-launcher-one-ring</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/04/28/personal-software-sw-launcher-one-ring/"><![CDATA[<p><img src="/assets/images/posts/block-one-ring.webp" class="post-marker no-invert" alt="" style="width: 220px;" /></p>

<div style="overflow: hidden;">

  <p>The sw-embed monorepos cover ten-plus languages targeting the same COR24 emulator: hand-written assembler, Forth, BASIC, Pascal, PL/SW, Macrolisp, OCaml-on-p-code, Smalltalk, Tuplet, Snobol4, APL — plus a resident-shell trio (monitor, script, yocto-ed) that does not look like a “language” at all but uses the same emulator and the same memory map. Every one of them solved the bottom-of-the-stack problem — <em>get the right bytes into the right addresses, in the right order, with the runtime patched to know where the upper layers live</em> — independently, with a hand-rolled <code class="language-plaintext highlighter-rouge">scripts/run-*.sh</code>. <a href="https://github.com/sw-cli-tools/sw-launcher">sw-launcher</a> is the personal-software CLI that consolidates that ritual into one declarative file with caching, validation, and a vendor lockfile. Phase 0 (the survey) is done, the schema has been revised <em>twice</em> in response to what the survey found, and Phase 1 (Scenario A end-to-end) actually runs.</p>

</div>

<div class="aside-box">

  <p><strong>Why this matters</strong> — AI coding agents working across multiple sw-embed repos do not have the patience or the pattern-matching to get the load plan right by inspection. They will happily write <code class="language-plaintext highlighter-rouge">cor24-run --load-binary out.bin@0 --load-binary app.p24@0x10000 --patch 0x12=0x10000 --entry 0</code> from scratch every time, sometimes inventing flags that don’t exist. The fix is not better agent prompts; it is removing the freedom to invent. <code class="language-plaintext highlighter-rouge">sw-launch run &lt;scenario&gt;</code> is the only verb the agent gets, the TOML is the only place memory-layout decisions live, and the schema makes oversized heaps argue for themselves before the validator accepts them.</p>

</div>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Repo</strong></td>
        <td><a href="https://github.com/sw-cli-tools/sw-launcher">sw-cli-tools/sw-launcher</a></td>
      </tr>
      <tr>
        <td><strong>13-repo survey</strong></td>
        <td><a href="https://github.com/sw-cli-tools/sw-launcher/blob/main/docs/survey/index.md">docs/survey/index.md</a> · <a href="https://github.com/sw-cli-tools/sw-launcher/blob/main/docs/survey/schema-gaps.md">schema-gaps.md</a></td>
      </tr>
      <tr>
        <td><strong>Memory stance</strong></td>
        <td><a href="https://github.com/sw-cli-tools/sw-launcher/blob/main/docs/memory-stance.md">docs/memory-stance.md</a> · <a href="https://github.com/sw-cli-tools/sw-launcher/blob/main/docs/heap-analysis.md">docs/heap-analysis.md</a></td>
      </tr>
      <tr>
        <td><strong>COR24 emulator</strong></td>
        <td><a href="https://github.com/sw-embed/cor24-rs">sw-embed/cor24-rs</a></td>
      </tr>
      <tr>
        <td><strong>Driven projects</strong></td>
        <td><a href="https://github.com/sw-embed/sw-cor24-pcode">sw-cor24-pcode</a> · <a href="https://github.com/sw-embed/sw-cor24-ocaml">sw-cor24-ocaml</a> · <a href="https://github.com/sw-embed/sw-cor24-pascal">sw-cor24-pascal</a> · <a href="https://github.com/sw-embed/sw-cor24-basic">sw-cor24-basic</a></td>
      </tr>
      <tr>
        <td><strong>Related AI Tools post</strong></td>
        <td><a href="/2026/04/27/sw-checklist-ratchet-ai-coding-agents/">AI Tools #3: sw-checklist — Reining In AI Coding Agents With a Code-Metrics Ratchet</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="the-problem-every-language-has-its-own-loader">The Problem: Every Language Has Its Own Loader</h2>

<p>The COR24 is a 24-bit machine with 1 MiB of SRAM, a 3 KiB EBR hardware stack, and an MMIO aperture at <code class="language-plaintext highlighter-rouge">0xFF0000</code>. The host-side <code class="language-plaintext highlighter-rouge">cor24-run</code> emulator accepts a small surface — <code class="language-plaintext highlighter-rouge">--load-binary path@hex_addr</code>, <code class="language-plaintext highlighter-rouge">--patch hex_addr=hex_value</code>, <code class="language-plaintext highlighter-rouge">--uart-input "..."</code>, <code class="language-plaintext highlighter-rouge">--entry</code>, <code class="language-plaintext highlighter-rouge">--speed</code>, <code class="language-plaintext highlighter-rouge">-n</code> — and that’s the universe.</p>

<p>What changes between repos is <em>what gets loaded where</em>, <em>which runtime word has to be patched to point at the layer above it</em>, and <em>how source and data ride the UART</em>. The Phase 0 survey looked at thirteen working repos and aggregated the patterns. A few from the comparison table:</p>

<table>
  <thead>
    <tr>
      <th>repo</th>
      <th style="text-align: right">loads</th>
      <th style="text-align: right">patches</th>
      <th>UART src</th>
      <th>UART data</th>
      <th>heap</th>
      <th>stack</th>
      <th>approx SRAM</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>basic</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">0</td>
      <td>yes</td>
      <td>no</td>
      <td>emb</td>
      <td>hw EBR</td>
      <td>~64 KiB</td>
    </tr>
    <tr>
      <td>forth</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td>yes</td>
      <td>no</td>
      <td>emb</td>
      <td>hw EBR</td>
      <td>~256 KiB</td>
    </tr>
    <tr>
      <td>macrolisp</td>
      <td style="text-align: right">0–1</td>
      <td style="text-align: right">0</td>
      <td>yes</td>
      <td>snapshot</td>
      <td>emb</td>
      <td>hw EBR</td>
      <td>~512 KiB</td>
    </tr>
    <tr>
      <td>ocaml</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">2</td>
      <td>yes</td>
      <td>post-EOT</td>
      <td>emb+res</td>
      <td>emb in pvm</td>
      <td>~512 KiB</td>
    </tr>
    <tr>
      <td>pascal</td>
      <td style="text-align: right">1–2</td>
      <td style="text-align: right">1</td>
      <td>yes</td>
      <td>no</td>
      <td>emb</td>
      <td>emb</td>
      <td>~64 KiB</td>
    </tr>
    <tr>
      <td>plsw</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td>yes</td>
      <td>no</td>
      <td>emb</td>
      <td>hw EBR</td>
      <td>~1 MiB</td>
    </tr>
    <tr>
      <td>snobol4</td>
      <td style="text-align: right">1–3</td>
      <td style="text-align: right">0</td>
      <td>yes</td>
      <td>mode-flag</td>
      <td>emb</td>
      <td>emb</td>
      <td>~128 KiB</td>
    </tr>
    <tr>
      <td>monitor</td>
      <td style="text-align: right">many</td>
      <td style="text-align: right">0</td>
      <td>no</td>
      <td>no</td>
      <td>emb</td>
      <td>hw EBR</td>
      <td>~64 KiB</td>
    </tr>
    <tr>
      <td>tuplet</td>
      <td style="text-align: right">3</td>
      <td style="text-align: right">2</td>
      <td>yes</td>
      <td>image@0x080000</td>
      <td>res</td>
      <td>emb in pvm</td>
      <td>~768 KiB</td>
    </tr>
  </tbody>
</table>

<p>Every project’s <code class="language-plaintext highlighter-rouge">scripts/run-*.sh</code> re-encodes one of these shapes. None of them validate. None of them cache. None of them notice when the OCaml heap and the DSL heap overlap. And every AI agent that touches these scripts adds its own subtle variation, because the shell script is the spec.</p>

<h2 id="two-axes-five-shapes">Two Axes, Five Shapes</h2>

<p>The original PRD assumed one axis with three points (A: single image, B: runtime+image, C: nested interpreter). The survey says it is actually two axes:</p>

<ul>
  <li><strong>Build axis</strong>: hand-written assembly, compiled from a higher-level language, snapshot rehydrated by host tooling, or composite of N modules linked host-side.</li>
  <li><strong>Run axis</strong>: one-shot batch (kick off and check UART), interactive REPL through UART, interactive shell with a resident process model, or edit-then-run via a resident editor.</li>
</ul>

<p>The cross product yields five primitive shapes that cover everything sw-embed has written so far. The first three were already in the day-zero design; the last two emerged from the survey:</p>

<ol>
  <li><strong>Single image at zero, UART source.</strong> Heap and stack embedded in the image. (apl, basic, forth, plsw, smalltalk-delegated.)</li>
  <li><strong>Runtime + image + patch.</strong> Native COR24 runtime at 0 plus a p-code image at a higher address with a <code class="language-plaintext highlighter-rouge">code_ptr</code>-style patch. (pascal single-unit and multi-unit, the OCaml/tuplet pattern without the heap patch.)</li>
  <li><strong>Nested interpreter with heap-limit patch and UART-after-EOT data.</strong> Adds a second patch (heap limit), and the UART payload is <code class="language-plaintext highlighter-rouge">&lt;source&gt; + EOT + &lt;runtime data&gt;</code>. (ocaml, tuplet.)</li>
  <li><strong>Multi-module composite image.</strong> The launcher loads N independently assembled modules at contiguous bases (snobol4 via <code class="language-plaintext highlighter-rouge">link24</code>) or at fixed slot addresses (macrolisp’s multi-module demo, monitor’s program registry). Linking happens host-side, not via patches.</li>
  <li><strong>Resident shell + paste-and-go.</strong> Monitor at 0, sws shell at <code class="language-plaintext highlighter-rouge">0x20000</code>, programs at fixed slots, all preloaded together; transfer of control happens <em>inside</em> the emulator via a service-vector / trampoline (<code class="language-plaintext highlighter-rouge">mon_invoke_program</code>) and never returns to the host runner. (monitor, script, yocto-ed.)</li>
</ol>

<p>A scenario picks one shape; the schema makes that pick explicit instead of implied by which shell script you happen to run.</p>

<h2 id="schema-v11-partition-grid-considered-then-rejected">Schema v1.1: Partition Grid (Considered, Then Rejected)</h2>

<p>The first revision after the survey, schema v1.1, divided the 1 MiB SRAM into eight fixed <strong>partitions</strong> of 128 KiB and four <strong>regions</strong> per partition (<code class="language-plaintext highlighter-rouge">code</code>/<code class="language-plaintext highlighter-rouge">heap</code>/<code class="language-plaintext highlighter-rouge">spare</code>/<code class="language-plaintext highlighter-rouge">stack</code>, 32 KiB each). Most existing repos already align to obvious partition boundaries (<code class="language-plaintext highlighter-rouge">0x000000</code>, <code class="language-plaintext highlighter-rouge">0x010000</code>, <code class="language-plaintext highlighter-rouge">0x040000</code>, <code class="language-plaintext highlighter-rouge">0x080000</code>, <code class="language-plaintext highlighter-rouge">0x0F0000</code>), so re-stating those addresses in <code class="language-plaintext highlighter-rouge">(partition, region)</code> coordinates was mostly a labeling change.</p>

<p>It was the wrong move. The grid canonized a <em>layout</em> without taking a position on the <em>budgets</em>, which let oversized heaps express themselves as multi-cell claims and call it normal:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># v1.1: OCaml's 252 KiB heap, expressed as four contiguous cells.</span>
<span class="c"># Schema accepts it, validator passes, nothing argues back.</span>
<span class="k">[</span><span class="n">layers</span><span class="k">.</span><span class="n">ocaml_interp</span><span class="k">.</span><span class="n">segments</span><span class="k">.</span><span class="n">value_heap</span><span class="k">]</span>
<span class="n">kind</span>   <span class="o">=</span><span class="w"> </span><span class="s">"heap"</span>
<span class="n">grows</span>  <span class="o">=</span><span class="w"> </span><span class="s">"down"</span>
<span class="n">claims</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span>
  <span class="p">{</span><span class="w"> </span><span class="n">partition</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"spare"</span><span class="w"> </span><span class="p">},</span>
  <span class="p">{</span><span class="w"> </span><span class="n">partition</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"stack"</span><span class="w"> </span><span class="p">},</span>
  <span class="p">{</span><span class="w"> </span><span class="n">partition</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"code"</span><span class="w">  </span><span class="p">},</span>
  <span class="p">{</span><span class="w"> </span><span class="n">partition</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"heap"</span><span class="w">  </span><span class="p">},</span>
<span class="p">]</span>
</code></pre></div></div>

<p>That’s the OCaml interpreter’s current <code class="language-plaintext highlighter-rouge">heap_limit = 0x03F000</code> written as a partition-cell list. Pinning down the layout this way looked like progress. It was actually normalization of the bug.</p>

<h2 id="schema-v12-memory-profiles--heap-budgets">Schema v1.2: Memory Profiles + Heap Budgets</h2>

<p>The second revision flipped the prior. From <a href="https://github.com/sw-cli-tools/sw-launcher/blob/main/docs/memory-stance.md"><code class="language-plaintext highlighter-rouge">docs/memory-stance.md</code></a>:</p>

<blockquote>
  <p>The COR24 board emulator targets 1 MiB SRAM. That is <em>more</em>, not less, than every machine these re-implemented languages were originally designed for: Forth in 4–16 KiB, BASIC in 4 KiB (Altair) to 32 KiB (MS BASIC for IBM PC), APL/360 in &lt;128 KiB per partition, Smalltalk-72/76 in 128–512 KiB <em>including the bitmap display</em>, Macrolisp on a PDP-10 with 256 KiB <em>total</em>. The IBM PC shipped in 1981 with 16–256 KiB. By 1985 measure, 1 MiB and a tiny monitor is a luxurious environment.</p>
</blockquote>

<p>If macrolisp on a PDP-10 fit in 256 KiB total — runtime, interpreter, and program — then the COR24 macrolisp’s ~288 KiB <em>heap</em> is not a constraint problem. Something has gone soft. The 1 MiB ceiling does not need to be raised. The heaps need to be shrunk.</p>

<p>v1.2 makes that the schema’s stance. Three concrete changes:</p>

<p><strong>1. The fixed grid is gone.</strong> Replaced with <strong>named memory profiles</strong>. Each profile is an ordered list of partitions of arbitrary size, each with its own list of named regions of arbitrary kind and size, plus a <em>budget</em> block:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">memory_profiles</span><span class="k">.</span><span class="n">compiled-app</span><span class="k">]</span>
<span class="n">description</span> <span class="o">=</span><span class="w"> </span><span class="s">"Single image at 0; small heap; small stack."</span>

<span class="k">[[</span><span class="n">memory_profiles</span><span class="k">.</span><span class="n">compiled-app</span><span class="k">.</span><span class="n">partitions</span><span class="k">]]</span>
<span class="n">name</span> <span class="o">=</span><span class="w"> </span><span class="s">"code"</span>
<span class="n">base</span> <span class="o">=</span><span class="w"> </span><span class="s">"0x000000"</span>
<span class="n">size</span> <span class="o">=</span><span class="w"> </span><span class="s">"0x010000"</span>            <span class="c"># 64 KiB</span>
<span class="n">regions</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span>
  <span class="p">{</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"code"</span><span class="p">,</span><span class="w">   </span><span class="n">kind</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"code"</span><span class="p">,</span><span class="w">  </span><span class="n">size</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"auto"</span><span class="w"> </span><span class="p">},</span>
  <span class="p">{</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"static"</span><span class="p">,</span><span class="w"> </span><span class="n">kind</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"data"</span><span class="p">,</span><span class="w">  </span><span class="n">size</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"auto"</span><span class="w"> </span><span class="p">},</span>
<span class="p">]</span>

<span class="k">[[</span><span class="n">memory_profiles</span><span class="k">.</span><span class="n">compiled-app</span><span class="k">.</span><span class="n">partitions</span><span class="k">]]</span>
<span class="n">name</span> <span class="o">=</span><span class="w"> </span><span class="s">"heap"</span>
<span class="n">base</span> <span class="o">=</span><span class="w"> </span><span class="s">"0x010000"</span>
<span class="n">size</span> <span class="o">=</span><span class="w"> </span><span class="s">"0x008000"</span>            <span class="c"># 32 KiB</span>
<span class="n">regions</span> <span class="o">=</span><span class="w"> </span><span class="p">[{</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"heap"</span><span class="p">,</span><span class="w"> </span><span class="n">kind</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"heap"</span><span class="p">,</span><span class="w"> </span><span class="n">size</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"0x008000"</span><span class="w"> </span><span class="p">}]</span>

<span class="k">[</span><span class="n">memory_profiles</span><span class="k">.</span><span class="n">compiled-app</span><span class="k">.</span><span class="n">budget</span><span class="k">]</span>
<span class="n">code_max</span>  <span class="o">=</span><span class="w"> </span><span class="s">"0x008000"</span>       <span class="c"># 32 KiB</span>
<span class="n">heap_max</span>  <span class="o">=</span><span class="w"> </span><span class="s">"0x004000"</span>       <span class="c"># 16 KiB</span>
<span class="n">stack_max</span> <span class="o">=</span><span class="w"> </span><span class="s">"0x002000"</span>       <span class="c"># 8 KiB</span>
<span class="n">total_max</span> <span class="o">=</span><span class="w"> </span><span class="s">"0x010000"</span>       <span class="c"># 64 KiB</span>
<span class="n">justification_required</span> <span class="o">=</span><span class="w"> </span><span class="kc">true</span>
</code></pre></div></div>

<p><strong>2. Five default profiles ship with the launcher</strong>, each sized per <a href="https://github.com/sw-cli-tools/sw-launcher/blob/main/docs/heap-analysis.md"><code class="language-plaintext highlighter-rouge">docs/heap-analysis.md</code></a>:</p>

<table>
  <thead>
    <tr>
      <th>profile</th>
      <th>code+data</th>
      <th>heap</th>
      <th>stack</th>
      <th>total</th>
      <th>example use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">compiled-app</code></td>
      <td>&lt;= 32 KiB</td>
      <td>&lt;= 16 KiB</td>
      <td>&lt;= 8 KiB</td>
      <td>&lt;= 64 KiB</td>
      <td>BASIC echo program</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">interpreter-only</code></td>
      <td>&lt;= 64 KiB</td>
      <td>&lt;= 64 KiB</td>
      <td>&lt;= 16 KiB</td>
      <td>&lt;= 160 KiB</td>
      <td>APL, Forth, Smalltalk</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">repl-inline-compile</code></td>
      <td>&lt;= 128 KiB</td>
      <td>&lt;= 256 KiB</td>
      <td>&lt;= 32 KiB</td>
      <td>&lt;= 448 KiB</td>
      <td>OCaml + GC, Tuplet (post-fix)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">compiler-image</code></td>
      <td>&lt;= 256 KiB</td>
      <td>&lt;= 64 KiB</td>
      <td>&lt;= 32 KiB</td>
      <td>&lt;= 384 KiB</td>
      <td>PL/SW (post-fix)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">resident-shell</code></td>
      <td>&lt;= 64 KiB per slot, up to 8 slots</td>
      <td>per-program</td>
      <td>shared 8 KiB</td>
      <td>&lt;= 512 KiB</td>
      <td>monitor + sws + N programs</td>
    </tr>
  </tbody>
</table>

<p>A scenario picks a profile by name; the validator enforces that profile’s budget. Layers cite partitions and regions by <em>name</em>, not by hex.</p>

<p><strong>3. Heaps over 32 KiB must argue for themselves</strong> through a <code class="language-plaintext highlighter-rouge">heap_justification</code> block:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">layers</span><span class="k">.</span><span class="n">ocaml_interp</span><span class="k">.</span><span class="n">heap_justification</span><span class="k">]</span>
<span class="n">category</span> <span class="o">=</span><span class="w"> </span><span class="s">"gc-slack"</span>
<span class="n">note</span>     <span class="o">=</span><span class="w"> </span><span class="s">"Mark/sweep GC; sized for working set + 2x slack."</span>
<span class="n">measured_floor_kib</span> <span class="o">=</span><span class="w"> </span><span class="mi">64</span>
<span class="n">tracking_issue</span>     <span class="o">=</span><span class="w"> </span><span class="s">"sw-cor24-ocaml#28"</span>
</code></pre></div></div>

<p>Five categories, in roughly descending order of merit:</p>

<table>
  <thead>
    <tr>
      <th>category</th>
      <th>accepted?</th>
      <th>meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">algorithmic-floor</code></td>
      <td>yes</td>
      <td>Working set genuinely requires this size.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bytecode-image</code></td>
      <td>yes</td>
      <td>Heap is mostly read-only data, not allocations.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gc-slack</code></td>
      <td>yes (with <code class="language-plaintext highlighter-rouge">measured_floor_kib</code>)</td>
      <td>Sized for floor + slack between collections.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dead-leak</code></td>
      <td>warn; rejected by <code class="language-plaintext highlighter-rouge">--strict</code></td>
      <td>Allocations that never get freed.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">algorithmic-bloat</code></td>
      <td>warn; rejected by <code class="language-plaintext highlighter-rouge">--strict</code></td>
      <td>Pointer width, boxing, dispatch tables, etc.</td>
    </tr>
  </tbody>
</table>

<p>The default category for an undocumented oversized heap is <code class="language-plaintext highlighter-rouge">dead-leak</code> — because the heap-analysis pass found that all three of the demanding repos (ocaml, macrolisp, plsw) match exactly that pattern, and the first job of a budget is to refuse to normalize them.</p>

<h2 id="what-the-heap-analysis-found">What the Heap Analysis Found</h2>

<p><a href="https://github.com/sw-cli-tools/sw-launcher/blob/main/docs/heap-analysis.md"><code class="language-plaintext highlighter-rouge">docs/heap-analysis.md</code></a> walks every repo with a claimed heap &gt; 32 KiB and assigns it a category, a historical benchmark, and a shrinkage backlog:</p>

<table>
  <thead>
    <tr>
      <th>repo</th>
      <th>current claim</th>
      <th>category</th>
      <th>historical floor</th>
      <th>post-fix target</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ocaml</td>
      <td>~252 KiB heap</td>
      <td>dead-leak</td>
      <td>OCaml-on-PDP-10 &lt; 256 KiB <em>total</em> (1973)</td>
      <td>&lt;= 64 KiB after GC</td>
    </tr>
    <tr>
      <td>tuplet</td>
      <td>inherits ocaml</td>
      <td>dead-leak</td>
      <td>n/a (downstream)</td>
      <td>shrinks with ocaml</td>
    </tr>
    <tr>
      <td>macrolisp</td>
      <td>~288 KiB BSS</td>
      <td>dead-leak + bloat</td>
      <td>Maclisp 256 KiB <em>total</em></td>
      <td>&lt;= 64 KiB heap</td>
    </tr>
    <tr>
      <td>plsw</td>
      <td>~1 MiB image</td>
      <td>algorithmic-bloat</td>
      <td>UCSD Pascal in 64 KiB; Turbo Pascal 1.0 in 33.5 KiB</td>
      <td>&lt;= 256 KiB image</td>
    </tr>
    <tr>
      <td>snobol4</td>
      <td>~76 KiB internal</td>
      <td>floor + dead-leak</td>
      <td>SNOBOL4 in 64–256 KiB <em>total</em></td>
      <td>&lt;= 64 KiB</td>
    </tr>
    <tr>
      <td>forth</td>
      <td>dictionary</td>
      <td>algorithmic-floor</td>
      <td>Forth kernels in 4–16 KiB</td>
      <td>&lt;= 16 KiB typical</td>
    </tr>
    <tr>
      <td>basic</td>
      <td>embedded DIM 64–128 KiB</td>
      <td>algorithmic-floor</td>
      <td>Altair 4K BASIC</td>
      <td>&lt;= 32 KiB</td>
    </tr>
  </tbody>
</table>

<p>OCaml’s GC work in <code class="language-plaintext highlighter-rouge">sw-cor24-ocaml#28</code> is the one in flight. After it lands, tuplet’s <code class="language-plaintext highlighter-rouge">heap_limit</code> should <em>shrink</em>, not stay where it is. Macrolisp’s mark byte should be a mark <em>bit</em> (8x reduction). PL/SW’s compiler-output redundancy is fixable in one pass through the transpiler. The schema must support the current sizes transitionally, but the analysis doc must not normalize them. The current sizes are evidence of work to do; not the spec for the launcher.</p>

<h2 id="layers-are-composites-not-blobs">Layers Are Composites, Not Blobs</h2>

<p>Every other piece of the schema survives both revisions. A layer is still <code class="language-plaintext highlighter-rouge">(artifact?) + (segments)</code>, and segments still have lifecycles:</p>

<ul>
  <li><strong>Embedded</strong> segments live inside the artifact. <code class="language-plaintext highlighter-rouge">pvm.s</code> reserves <code class="language-plaintext highlighter-rouge">eval_stack</code>, <code class="language-plaintext highlighter-rouge">call_stack</code>, and a small <code class="language-plaintext highlighter-rouge">heap_seg</code> statically; they are part of <code class="language-plaintext highlighter-rouge">pvm.bin</code> and the loader does not allocate them again, but the validator has to <em>know</em> they exist (resolved through the artifact’s listing) so the global overlap check sees them.</li>
  <li><strong>Reserved</strong> segments are allocated by the loader at a configured cell with a configured size and zero-filled. The OCaml interpreter’s value heap, the Pascal eval stack, the DSL heap on top — none of these fit in the COR24’s 3 KiB EBR; they live in high SRAM cells and the runtime gets <em>patched</em> to point at them.</li>
  <li><strong>Patched</strong> is the verb that ties the two together. A reserved segment with <code class="language-plaintext highlighter-rouge">value = "self.address"</code> (or <code class="language-plaintext highlighter-rouge">self.end</code> for down-growing heaps) tells the loader to write its own resolved address into the runtime’s <code class="language-plaintext highlighter-rouge">heap_base</code> / <code class="language-plaintext highlighter-rouge">heap_limit</code> symbol, so the runtime knows where to find the heap the loader just allocated for it.</li>
</ul>

<p>Patches in v1.2 also accept two value forms that the survey demanded:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">value = "sidecar:&lt;path&gt;"</code> reads a build-time-resolved address from a small text file. ocaml and tuplet today write <code class="language-plaintext highlighter-rouge">build/code_ptr_addr.txt</code> and <code class="language-plaintext highlighter-rouge">build/heap_limit_addr.txt</code> during their build; the schema makes that explicit and includes the sidecar in the cache key.</li>
  <li><code class="language-plaintext highlighter-rouge">target = "&lt;upstream-layer&gt;.&lt;symbol&gt;"</code> lets a downstream layer reference a symbol in an upstream layer’s listing — including upstream layers from a <em>vendored</em> repo (Phase 2 step 002 just landed the resolver for this; tuplet wants pvm symbols from sw-cor24-ocaml’s build, not its own).</li>
</ul>

<h2 id="the-wizards-spellbook--sw-launchtoml">The Wizard’s Spellbook — sw-launch.toml</h2>

<div class="gutter-section">

  <p><img src="/assets/images/posts/block-wizard.webp" class="gutter-img-right no-invert" alt="Wizard with staff" style="width: 18%;" /></p>

  <p>The whole config is one file at the project root. A trimmed Scenario A example, written against the <code class="language-plaintext highlighter-rouge">compiled-app</code> profile, that <em>actually runs end-to-end</em> on a real <code class="language-plaintext highlighter-rouge">cor24-run</code>:</p>

  <div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">scenarios</span><span class="k">.</span><span class="n">echo</span><span class="k">]</span>
<span class="n">target</span>         <span class="o">=</span><span class="w"> </span><span class="s">"cor24"</span>
<span class="n">memory_profile</span> <span class="o">=</span><span class="w"> </span><span class="s">"compiled-app"</span>
<span class="n">layers</span>         <span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="s">"program"</span><span class="p">,</span> <span class="s">"stdin"</span><span class="p">]</span>
<span class="n">entry</span>          <span class="o">=</span><span class="w"> </span><span class="s">"0x000000"</span>

<span class="k">[</span><span class="n">scenarios</span><span class="k">.</span><span class="n">echo</span><span class="k">.</span><span class="n">run</span><span class="k">]</span>
<span class="n">timeout_ms</span> <span class="o">=</span><span class="w"> </span><span class="mi">2000</span>
<span class="n">max_cycles</span> <span class="o">=</span><span class="w"> </span><span class="mi">200_000</span>
<span class="n">halt_on</span>    <span class="o">=</span><span class="w"> </span><span class="s">"uart-eot"</span>

<span class="k">[</span><span class="n">scenarios</span><span class="k">.</span><span class="n">echo</span><span class="k">.</span><span class="n">expect</span><span class="k">]</span>
<span class="n">uart_contains</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="s">"A"</span><span class="p">]</span>
<span class="n">exit_code</span>     <span class="o">=</span><span class="w"> </span><span class="mi">0</span>

<span class="k">[</span><span class="n">layers</span><span class="k">.</span><span class="n">program</span><span class="k">]</span>
<span class="n">kind</span>     <span class="o">=</span><span class="w"> </span><span class="s">"assembler"</span>
<span class="n">source</span>   <span class="o">=</span><span class="w"> </span><span class="s">"local"</span>
<span class="n">input</span>    <span class="o">=</span><span class="w"> </span><span class="s">"src/echo.s"</span>
<span class="n">tool</span>     <span class="o">=</span><span class="w"> </span><span class="s">"assembler"</span>
<span class="n">artifact</span> <span class="o">=</span><span class="w"> </span><span class="s">"echo.bin"</span>

<span class="k">[</span><span class="n">layers</span><span class="k">.</span><span class="n">program</span><span class="k">.</span><span class="n">segments</span><span class="k">.</span><span class="n">code</span><span class="k">]</span>
<span class="n">kind</span>   <span class="o">=</span><span class="w"> </span><span class="s">"code"</span>
<span class="n">claims</span> <span class="o">=</span><span class="w"> </span><span class="p">[{</span><span class="w"> </span><span class="n">partition</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"code"</span><span class="p">,</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"code"</span><span class="w"> </span><span class="p">}]</span>

<span class="k">[</span><span class="n">layers</span><span class="k">.</span><span class="n">stdin</span><span class="k">]</span>
<span class="n">kind</span> <span class="o">=</span><span class="w"> </span><span class="s">"data"</span>
<span class="n">input</span> <span class="o">=</span><span class="w"> </span><span class="s">"tests/echo-input.txt"</span>
<span class="n">load</span><span class="p">.</span><span class="n">method</span> <span class="o">=</span><span class="w"> </span><span class="s">"uart"</span>
<span class="n">load</span><span class="p">.</span><span class="n">max_bytes</span> <span class="o">=</span><span class="w"> </span><span class="mi">1024</span>
</code></pre></div>  </div>

  <p><code class="language-plaintext highlighter-rouge">sw-launch run echo</code> walks the layer DAG, builds each layer (or pulls it from the in-process memoization cache), assembles the load plan, invokes <code class="language-plaintext highlighter-rouge">cor24-run</code>, captures UART output, and checks the expectations. Today this prints <code class="language-plaintext highlighter-rouge">A</code> and exits 0. The CLI surface is small enough to memorize:</p>

  <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sw-launch run     &lt;scenario&gt;    Build (with cache) and execute, check expectations.
sw-launch build   &lt;scenario&gt;    Build all layers; do not execute.
sw-launch check   &lt;scenario&gt;    Validate config + lock; no tools run.
sw-launch graph   &lt;scenario&gt;    Print layer DAG (text or --json).
sw-launch cache   list          List cached artifacts.
sw-launch vendor  sync          Resolve and pin all dependencies.
sw-launch doctor                Verify host tools (cor24-run, pa24r, pl24r) found.
</code></pre></div>  </div>

  <p>Every flag agents used to invent is now either a TOML field or a <code class="language-plaintext highlighter-rouge">--profile</code>. There is no <code class="language-plaintext highlighter-rouge">--load-binary</code> to mistype.</p>

</div>

<h2 id="phase-1-what-runs-today">Phase 1: What Runs Today</h2>

<p>The Phase 1 saga (<code class="language-plaintext highlighter-rouge">sw-launcher-phase1</code>, ten steps) closed clean on April 28. End state:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">sw-launch run echo --config tests/fixtures/scenario_a/sw-launch.toml</code> exits 0 and prints <code class="language-plaintext highlighter-rouge">A</code> (the captured UART output).</li>
  <li><code class="language-plaintext highlighter-rouge">sw-launch check echo</code> validates the scenario without spawning the emulator.</li>
  <li><code class="language-plaintext highlighter-rouge">sw-launch build echo</code> assembles every assembler-kind layer under <code class="language-plaintext highlighter-rouge">&lt;config-dir&gt;/.sw-launch/build/&lt;scenario&gt;/&lt;layer&gt;/</code> with a sha256-keyed in-process memoization cache.</li>
  <li>60 tests across 11 binaries, including 2 end-to-end against the real <code class="language-plaintext highlighter-rouge">cor24-run</code> binary, all green.</li>
  <li><code class="language-plaintext highlighter-rouge">validate.rs</code> implements 17 stable error codes, each with a negative test asserting the exact code and span.</li>
</ul>

<p>The integration tests ran against <code class="language-plaintext highlighter-rouge">cor24-run 0.1.0</code>, <code class="language-plaintext highlighter-rouge">rustc 1.94.1</code>, edition 2024, on Darwin 24.6.0. Every one of those versions is recorded in the repo’s <code class="language-plaintext highlighter-rouge">status.md</code> so future-me knows what “Phase 1 worked” actually meant.</p>

<p>Phase 2 (<code class="language-plaintext highlighter-rouge">sw-launcher-phase2</code>) is open and seeded with five steps; step 001 (PCode tool with SourceSpec resolution) and step 002 (cross-layer listing-symbol patches resolve at scenario validate time) just landed. Phase 2’s target is end-to-end Scenario B: COR24 runtime at 0 plus a p-code blob at a higher address with a <code class="language-plaintext highlighter-rouge">code_ptr</code> patch — the smallest meaningful test that the launcher can express the layered shape that today’s <code class="language-plaintext highlighter-rouge">sw-cor24-pcode</code> and <code class="language-plaintext highlighter-rouge">sw-cor24-pascal</code> demos use.</p>

<h2 id="validation-catch-collisions-before-the-emulator-does">Validation: Catch Collisions Before the Emulator Does</h2>

<p>The validator is the most important verb. <code class="language-plaintext highlighter-rouge">sw-launch check</code> reads the TOML and the lockfile, walks every segment of every layer in the scenario, computes absolute address ranges (resolving profile partitions to addresses, embedded segments through the producing layer’s listing, sidecars from disk), and runs the rules. Each rule has a stable error code so agents can match on it without scraping prose.</p>

<p>A subset, selected for what they catch:</p>

<table>
  <thead>
    <tr>
      <th>Code</th>
      <th>Rule</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>E0003</td>
      <td>No two memory ranges overlap (across embedded <em>and</em> reserved segments).</td>
    </tr>
    <tr>
      <td>E0004</td>
      <td>UART layers declare <code class="language-plaintext highlighter-rouge">max_bytes</code> and the input fits.</td>
    </tr>
    <tr>
      <td>E0005</td>
      <td>Layer kind and load method are compatible.</td>
    </tr>
    <tr>
      <td>E0006</td>
      <td>Every patch resolves — symbol exists, segment exists, topo order is right.</td>
    </tr>
    <tr>
      <td>E0011</td>
      <td>Reserved <code class="language-plaintext highlighter-rouge">stack</code>/<code class="language-plaintext highlighter-rouge">heap</code>/<code class="language-plaintext highlighter-rouge">bss</code> lies inside <code class="language-plaintext highlighter-rouge">regions.sram</code>, never touches <code class="language-plaintext highlighter-rouge">regions.ebr_stack</code> or <code class="language-plaintext highlighter-rouge">regions.mmio</code>.</td>
    </tr>
    <tr>
      <td>E0014</td>
      <td><code class="language-plaintext highlighter-rouge">embedded = true</code> segments declare a <code class="language-plaintext highlighter-rouge">symbol</code> that resolves in the producing layer’s listing.</td>
    </tr>
    <tr>
      <td>E0023</td>
      <td>Resident-mode mismatch (a layer claims a slot the resident shell doesn’t expose).</td>
    </tr>
    <tr>
      <td>E0028</td>
      <td>Heap-budget overshoot (heap exceeds the profile’s <code class="language-plaintext highlighter-rouge">heap_max</code>).</td>
    </tr>
    <tr>
      <td>E0029</td>
      <td>Heap &gt;= 80% of <code class="language-plaintext highlighter-rouge">heap_max</code> (warn).</td>
    </tr>
    <tr>
      <td>E0030</td>
      <td>Missing <code class="language-plaintext highlighter-rouge">heap_justification</code> for heap &gt; 32 KiB.</td>
    </tr>
    <tr>
      <td>E0031</td>
      <td>Layer cites a partition or region the profile does not declare.</td>
    </tr>
    <tr>
      <td>E0032</td>
      <td>Profile self-overlap (the profile’s own partitions collide).</td>
    </tr>
    <tr>
      <td>E0033</td>
      <td>Total claimed SRAM &gt; 1 MiB.</td>
    </tr>
    <tr>
      <td>E0034</td>
      <td><code class="language-plaintext highlighter-rouge">heap_justification.category = "dead-leak"</code> or <code class="language-plaintext highlighter-rouge">"algorithmic-bloat"</code> under <code class="language-plaintext highlighter-rouge">--strict</code>.</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">--strict</code> mode promotes most warnings to errors and <em>always</em> rejects <code class="language-plaintext highlighter-rouge">dead-leak</code> and <code class="language-plaintext highlighter-rouge">algorithmic-bloat</code> justifications. CI runs strict; local <code class="language-plaintext highlighter-rouge">check</code> runs lax so the shrinkage work can land incrementally without breaking the build.</p>

<h2 id="caching-vendoring-and-the-lockfile">Caching, Vendoring, and the Lockfile</h2>

<p>The cost of <em>not</em> caching is that every test re-assembles <code class="language-plaintext highlighter-rouge">pvm.s</code>, every demo re-builds the host toolchain, every CI run wastes minutes. Phase 1 ships an in-process memoization cache (sha256 keyed on <code class="language-plaintext highlighter-rouge">(input bytes, tool version, args, output filename)</code>) inside the assembler tool wrapper; Phase 4 will lift that to a persistent on-disk cache at <code class="language-plaintext highlighter-rouge">~/.cache/sw-launch/</code>. The key formula already accounts for the hard cases:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>layer_key = sha256(
    schema_version
  | normalize_toml(layer_config)
  | hash_each(input_files)
  | tool_version_hash
  | dependency_layer_hashes (in topo order)
  | resolved_address_or_uart_marker
  | sidecar_contents (if any)
)
</code></pre></div></div>

<p>A cache hit is <em>sound</em> — if the key matches, the artifact would be byte-identical to a fresh build, so reusing it can never produce a different scenario result. Sidecar contents are in the key because v1.2’s <code class="language-plaintext highlighter-rouge">value = "sidecar:..."</code> patch source needs the cache to invalidate when the sidecar changes.</p>

<p>The vendor side is symmetric. The survey was unflattering — ten of thirteen repos pin nothing at all, and one (tuplet) inherits its pins transitively from sw-cor24-ocaml. Only sw-cor24-ocaml has a real <code class="language-plaintext highlighter-rouge">vendor/&lt;tool&gt;/&lt;version&gt;/active.env</code> model with commit SHAs. v1.2 makes the OCaml-style vendored model the default: <code class="language-plaintext highlighter-rouge">sw-launch vendor sync</code> (Phase 4) will resolve declared dependencies (<code class="language-plaintext highlighter-rouge">sibling:</code>, <code class="language-plaintext highlighter-rouge">vendor:</code>, eventually <code class="language-plaintext highlighter-rouge">git:</code>) and write <code class="language-plaintext highlighter-rouge">sw-launch.lock</code> with each artifact’s commit hash and SHA. <code class="language-plaintext highlighter-rouge">sw-launch doctor</code> will record the <em>observed</em> version of each PATH-resolved tool too, so drift is visible even when nothing is explicitly pinned.</p>

<h2 id="what-this-buys">What This Buys</h2>

<p>The point is not that the TOML is shorter than the shell script — it is often longer. The point is what <em>changes</em> about the system:</p>

<ol>
  <li><strong>One verb for agents.</strong> <code class="language-plaintext highlighter-rouge">sw-launch run &lt;scenario&gt;</code> replaces the ten variants of <code class="language-plaintext highlighter-rouge">cor24-run --load-binary ...</code> that agents kept reinventing.</li>
  <li><strong>Validation before the emulator.</strong> The most expensive failure mode — “ran for forty seconds, traps silently, zero output” — is replaced by a fast <code class="language-plaintext highlighter-rouge">check</code> that names the colliding region and the offending TOML span.</li>
  <li><strong>Heaps argue for themselves.</strong> A 252 KiB heap is not waved through; it has to declare a category, a measured floor, and a tracking issue. <code class="language-plaintext highlighter-rouge">--strict</code> rejects <code class="language-plaintext highlighter-rouge">dead-leak</code> and <code class="language-plaintext highlighter-rouge">algorithmic-bloat</code> outright.</li>
  <li><strong>A schema for memory layout.</strong> Reserved heaps, embedded stacks, profile-named partitions, sidecar patches, cross-layer symbol references — all first-class in TOML. A new language port describes its memory shape; it does not write a new shell loader.</li>
  <li><strong>Vendor versions visible.</strong> Sibling-repo dependencies are pinned by commit hash in the lockfile, not by “whatever was checked out at 3pm.” PATH-resolved tools have their observed versions recorded too.</li>
  <li><strong>Phase 1 actually runs.</strong> The “what runs today” section is not a roadmap; it is what <code class="language-plaintext highlighter-rouge">cargo test</code> exercises, end-to-end, against a real emulator.</li>
</ol>

<p>The wizard metaphor still fits the post marker: thirteen repos, each with its own ring of power, all in the end answering to one. sw-launcher is <em>the one ring</em> in the boring sense — the one place to encode the load plan — not in the corrupting sense, hopefully. But the more accurate metaphor for v1.2 is that the schema is a <em>budget officer</em>: every heap that wants more than 32 KiB has to file paperwork, and the default verdict on the paperwork is “this is a leak; prove otherwise.”</p>

<h2 id="where-it-sits-in-the-personal-software-toolkit">Where It Sits in the Personal-Software Toolkit</h2>

<p><a href="/2026/04/27/sw-checklist-ratchet-ai-coding-agents/">sw-checklist</a>, now part of the AI Tools series, sits at a different layer of the same problem: AI agents working alone produce too much variety in places where uniformity is cheaper. sw-checklist constrains the <em>shape of the code</em> (function/file/module/crate size limits); sw-launcher constrains the <em>shape of the load plan and the memory budget</em> (which addresses, which patches, how big a heap can grow before it has to file paperwork). Both are accidental complexity in Brooks’s sense. Both are paying rent.</p>

<p>Phase 0 (survey) and Phase 1 (Scenario A end-to-end) are done. Schema v1.1 was a wrong turn that taught the right lesson: do not canonize the layout without taking a position on the budgets. Schema v1.2 takes that position, in writing, with five default profiles sized against historical implementations from the 1970s and 1980s. Phase 2 (Scenario B, runtime + p-code blob with cross-layer patches) is in flight; Phase 3 brings Scenario C (nested interpreter, heap-limit-only patches — the tuplet shape); Phase 4 brings persistent caching and vendor sync; Phase 5 brings the resident-shell composite (D + E).</p>

<p>The wizard is just the post marker. The one ring is just the metaphor. The interesting part is the budget officer.</p>]]></content><author><name>Software Wrighter</name></author><category term="cli-tools" /><category term="rust" /><category term="embedded" /><category term="sw-launcher" /><category term="personal-software" /><category term="rust" /><category term="cli" /><category term="cor24" /><category term="sw-embed" /><category term="emulator" /><category term="memory-layout" /><category term="memory-profiles" /><category term="heap-budgets" /><category term="p-code" /><category term="vendoring" /><category term="agentrail" /><category term="ai-coding" /><summary type="html"><![CDATA[The sw-embed monorepos cover ten-plus languages targeting the same COR24 emulator: hand-written assembler, Forth, BASIC, Pascal, PL/SW, Macrolisp, OCaml-on-p-code, Smalltalk, Tuplet, Snobol4, APL — plus a resident-shell trio (monitor, script, yocto-ed) that does not look like a “language” at all but uses the same emulator and the same memory map. Every one of them solved the bottom-of-the-stack problem — get the right bytes into the right addresses, in the right order, with the runtime patched to know where the upper layers live — independently, with a hand-rolled scripts/run-*.sh. sw-launcher is the personal-software CLI that consolidates that ritual into one declarative file with caching, validation, and a vendor lockfile. Phase 0 (the survey) is done, the schema has been revised twice in response to what the survey found, and Phase 1 (Scenario A end-to-end) actually runs. Why this matters — AI coding agents working across multiple sw-embed repos do not have the patience or the pattern-matching to get the load plan right by inspection. They will happily write cor24-run --load-binary out.bin@0 --load-binary app.p24@0x10000 --patch 0x12=0x10000 --entry 0 from scratch every time, sometimes inventing flags that don’t exist. The fix is not better agent prompts; it is removing the freedom to invent. sw-launch run &lt;scenario&gt; is the only verb the agent gets, the TOML is the only place memory-layout decisions live, and the schema makes oversized heaps argue for themselves before the validator accepts them. Resource Link Repo sw-cli-tools/sw-launcher 13-repo survey docs/survey/index.md · schema-gaps.md Memory stance docs/memory-stance.md · docs/heap-analysis.md COR24 emulator sw-embed/cor24-rs Driven projects sw-cor24-pcode · sw-cor24-ocaml · sw-cor24-pascal · sw-cor24-basic Related AI Tools post AI Tools #3: sw-checklist — Reining In AI Coding Agents With a Code-Metrics Ratchet Comments Discord The Problem: Every Language Has Its Own Loader The COR24 is a 24-bit machine with 1 MiB of SRAM, a 3 KiB EBR hardware stack, and an MMIO aperture at 0xFF0000. The host-side cor24-run emulator accepts a small surface — --load-binary path@hex_addr, --patch hex_addr=hex_value, --uart-input "...", --entry, --speed, -n — and that’s the universe. What changes between repos is what gets loaded where, which runtime word has to be patched to point at the layer above it, and how source and data ride the UART. The Phase 0 survey looked at thirteen working repos and aggregated the patterns. A few from the comparison table: repo loads patches UART src UART data heap stack approx SRAM basic 1 0 yes no emb hw EBR ~64 KiB forth 0 0 yes no emb hw EBR ~256 KiB macrolisp 0–1 0 yes snapshot emb hw EBR ~512 KiB ocaml 2 2 yes post-EOT emb+res emb in pvm ~512 KiB pascal 1–2 1 yes no emb emb ~64 KiB plsw 0 0 yes no emb hw EBR ~1 MiB snobol4 1–3 0 yes mode-flag emb emb ~128 KiB monitor many 0 no no emb hw EBR ~64 KiB tuplet 3 2 yes image@0x080000 res emb in pvm ~768 KiB Every project’s scripts/run-*.sh re-encodes one of these shapes. None of them validate. None of them cache. None of them notice when the OCaml heap and the DSL heap overlap. And every AI agent that touches these scripts adds its own subtle variation, because the shell script is the spec. Two Axes, Five Shapes The original PRD assumed one axis with three points (A: single image, B: runtime+image, C: nested interpreter). The survey says it is actually two axes: Build axis: hand-written assembly, compiled from a higher-level language, snapshot rehydrated by host tooling, or composite of N modules linked host-side. Run axis: one-shot batch (kick off and check UART), interactive REPL through UART, interactive shell with a resident process model, or edit-then-run via a resident editor. The cross product yields five primitive shapes that cover everything sw-embed has written so far. The first three were already in the day-zero design; the last two emerged from the survey: Single image at zero, UART source. Heap and stack embedded in the image. (apl, basic, forth, plsw, smalltalk-delegated.) Runtime + image + patch. Native COR24 runtime at 0 plus a p-code image at a higher address with a code_ptr-style patch. (pascal single-unit and multi-unit, the OCaml/tuplet pattern without the heap patch.) Nested interpreter with heap-limit patch and UART-after-EOT data. Adds a second patch (heap limit), and the UART payload is &lt;source&gt; + EOT + &lt;runtime data&gt;. (ocaml, tuplet.) Multi-module composite image. The launcher loads N independently assembled modules at contiguous bases (snobol4 via link24) or at fixed slot addresses (macrolisp’s multi-module demo, monitor’s program registry). Linking happens host-side, not via patches. Resident shell + paste-and-go. Monitor at 0, sws shell at 0x20000, programs at fixed slots, all preloaded together; transfer of control happens inside the emulator via a service-vector / trampoline (mon_invoke_program) and never returns to the host runner. (monitor, script, yocto-ed.) A scenario picks one shape; the schema makes that pick explicit instead of implied by which shell script you happen to run. Schema v1.1: Partition Grid (Considered, Then Rejected) The first revision after the survey, schema v1.1, divided the 1 MiB SRAM into eight fixed partitions of 128 KiB and four regions per partition (code/heap/spare/stack, 32 KiB each). Most existing repos already align to obvious partition boundaries (0x000000, 0x010000, 0x040000, 0x080000, 0x0F0000), so re-stating those addresses in (partition, region) coordinates was mostly a labeling change. It was the wrong move. The grid canonized a layout without taking a position on the budgets, which let oversized heaps express themselves as multi-cell claims and call it normal: # v1.1: OCaml's 252 KiB heap, expressed as four contiguous cells. # Schema accepts it, validator passes, nothing argues back. [layers.ocaml_interp.segments.value_heap] kind = "heap" grows = "down" claims = [ { partition = 0, region = "spare" }, { partition = 0, region = "stack" }, { partition = 1, region = "code" }, { partition = 1, region = "heap" }, ] That’s the OCaml interpreter’s current heap_limit = 0x03F000 written as a partition-cell list. Pinning down the layout this way looked like progress. It was actually normalization of the bug. Schema v1.2: Memory Profiles + Heap Budgets The second revision flipped the prior. From docs/memory-stance.md: The COR24 board emulator targets 1 MiB SRAM. That is more, not less, than every machine these re-implemented languages were originally designed for: Forth in 4–16 KiB, BASIC in 4 KiB (Altair) to 32 KiB (MS BASIC for IBM PC), APL/360 in &lt;128 KiB per partition, Smalltalk-72/76 in 128–512 KiB including the bitmap display, Macrolisp on a PDP-10 with 256 KiB total. The IBM PC shipped in 1981 with 16–256 KiB. By 1985 measure, 1 MiB and a tiny monitor is a luxurious environment. If macrolisp on a PDP-10 fit in 256 KiB total — runtime, interpreter, and program — then the COR24 macrolisp’s ~288 KiB heap is not a constraint problem. Something has gone soft. The 1 MiB ceiling does not need to be raised. The heaps need to be shrunk. v1.2 makes that the schema’s stance. Three concrete changes: 1. The fixed grid is gone. Replaced with named memory profiles. Each profile is an ordered list of partitions of arbitrary size, each with its own list of named regions of arbitrary kind and size, plus a budget block: [memory_profiles.compiled-app] description = "Single image at 0; small heap; small stack." [[memory_profiles.compiled-app.partitions]] name = "code" base = "0x000000" size = "0x010000" # 64 KiB regions = [ { name = "code", kind = "code", size = "auto" }, { name = "static", kind = "data", size = "auto" }, ] [[memory_profiles.compiled-app.partitions]] name = "heap" base = "0x010000" size = "0x008000" # 32 KiB regions = [{ name = "heap", kind = "heap", size = "0x008000" }] [memory_profiles.compiled-app.budget] code_max = "0x008000" # 32 KiB heap_max = "0x004000" # 16 KiB stack_max = "0x002000" # 8 KiB total_max = "0x010000" # 64 KiB justification_required = true 2. Five default profiles ship with the launcher, each sized per docs/heap-analysis.md: profile code+data heap stack total example use compiled-app &lt;= 32 KiB &lt;= 16 KiB &lt;= 8 KiB &lt;= 64 KiB BASIC echo program interpreter-only &lt;= 64 KiB &lt;= 64 KiB &lt;= 16 KiB &lt;= 160 KiB APL, Forth, Smalltalk repl-inline-compile &lt;= 128 KiB &lt;= 256 KiB &lt;= 32 KiB &lt;= 448 KiB OCaml + GC, Tuplet (post-fix) compiler-image &lt;= 256 KiB &lt;= 64 KiB &lt;= 32 KiB &lt;= 384 KiB PL/SW (post-fix) resident-shell &lt;= 64 KiB per slot, up to 8 slots per-program shared 8 KiB &lt;= 512 KiB monitor + sws + N programs A scenario picks a profile by name; the validator enforces that profile’s budget. Layers cite partitions and regions by name, not by hex. 3. Heaps over 32 KiB must argue for themselves through a heap_justification block: [layers.ocaml_interp.heap_justification] category = "gc-slack" note = "Mark/sweep GC; sized for working set + 2x slack." measured_floor_kib = 64 tracking_issue = "sw-cor24-ocaml#28" Five categories, in roughly descending order of merit: category accepted? meaning algorithmic-floor yes Working set genuinely requires this size. bytecode-image yes Heap is mostly read-only data, not allocations. gc-slack yes (with measured_floor_kib) Sized for floor + slack between collections. dead-leak warn; rejected by --strict Allocations that never get freed. algorithmic-bloat warn; rejected by --strict Pointer width, boxing, dispatch tables, etc. The default category for an undocumented oversized heap is dead-leak — because the heap-analysis pass found that all three of the demanding repos (ocaml, macrolisp, plsw) match exactly that pattern, and the first job of a budget is to refuse to normalize them. What the Heap Analysis Found docs/heap-analysis.md walks every repo with a claimed heap &gt; 32 KiB and assigns it a category, a historical benchmark, and a shrinkage backlog: repo current claim category historical floor post-fix target ocaml ~252 KiB heap dead-leak OCaml-on-PDP-10 &lt; 256 KiB total (1973) &lt;= 64 KiB after GC tuplet inherits ocaml dead-leak n/a (downstream) shrinks with ocaml macrolisp ~288 KiB BSS dead-leak + bloat Maclisp 256 KiB total &lt;= 64 KiB heap plsw ~1 MiB image algorithmic-bloat UCSD Pascal in 64 KiB; Turbo Pascal 1.0 in 33.5 KiB &lt;= 256 KiB image snobol4 ~76 KiB internal floor + dead-leak SNOBOL4 in 64–256 KiB total &lt;= 64 KiB forth dictionary algorithmic-floor Forth kernels in 4–16 KiB &lt;= 16 KiB typical basic embedded DIM 64–128 KiB algorithmic-floor Altair 4K BASIC &lt;= 32 KiB OCaml’s GC work in sw-cor24-ocaml#28 is the one in flight. After it lands, tuplet’s heap_limit should shrink, not stay where it is. Macrolisp’s mark byte should be a mark bit (8x reduction). PL/SW’s compiler-output redundancy is fixable in one pass through the transpiler. The schema must support the current sizes transitionally, but the analysis doc must not normalize them. The current sizes are evidence of work to do; not the spec for the launcher. Layers Are Composites, Not Blobs Every other piece of the schema survives both revisions. A layer is still (artifact?) + (segments), and segments still have lifecycles: Embedded segments live inside the artifact. pvm.s reserves eval_stack, call_stack, and a small heap_seg statically; they are part of pvm.bin and the loader does not allocate them again, but the validator has to know they exist (resolved through the artifact’s listing) so the global overlap check sees them. Reserved segments are allocated by the loader at a configured cell with a configured size and zero-filled. The OCaml interpreter’s value heap, the Pascal eval stack, the DSL heap on top — none of these fit in the COR24’s 3 KiB EBR; they live in high SRAM cells and the runtime gets patched to point at them. Patched is the verb that ties the two together. A reserved segment with value = "self.address" (or self.end for down-growing heaps) tells the loader to write its own resolved address into the runtime’s heap_base / heap_limit symbol, so the runtime knows where to find the heap the loader just allocated for it. Patches in v1.2 also accept two value forms that the survey demanded: value = "sidecar:&lt;path&gt;" reads a build-time-resolved address from a small text file. ocaml and tuplet today write build/code_ptr_addr.txt and build/heap_limit_addr.txt during their build; the schema makes that explicit and includes the sidecar in the cache key. target = "&lt;upstream-layer&gt;.&lt;symbol&gt;" lets a downstream layer reference a symbol in an upstream layer’s listing — including upstream layers from a vendored repo (Phase 2 step 002 just landed the resolver for this; tuplet wants pvm symbols from sw-cor24-ocaml’s build, not its own). The Wizard’s Spellbook — sw-launch.toml The whole config is one file at the project root. A trimmed Scenario A example, written against the compiled-app profile, that actually runs end-to-end on a real cor24-run: [scenarios.echo] target = "cor24" memory_profile = "compiled-app" layers = ["program", "stdin"] entry = "0x000000" [scenarios.echo.run] timeout_ms = 2000 max_cycles = 200_000 halt_on = "uart-eot" [scenarios.echo.expect] uart_contains = ["A"] exit_code = 0 [layers.program] kind = "assembler" source = "local" input = "src/echo.s" tool = "assembler" artifact = "echo.bin" [layers.program.segments.code] kind = "code" claims = [{ partition = "code", region = "code" }] [layers.stdin] kind = "data" input = "tests/echo-input.txt" load.method = "uart" load.max_bytes = 1024 sw-launch run echo walks the layer DAG, builds each layer (or pulls it from the in-process memoization cache), assembles the load plan, invokes cor24-run, captures UART output, and checks the expectations. Today this prints A and exits 0. The CLI surface is small enough to memorize: sw-launch run &lt;scenario&gt; Build (with cache) and execute, check expectations. sw-launch build &lt;scenario&gt; Build all layers; do not execute. sw-launch check &lt;scenario&gt; Validate config + lock; no tools run. sw-launch graph &lt;scenario&gt; Print layer DAG (text or --json). sw-launch cache list List cached artifacts. sw-launch vendor sync Resolve and pin all dependencies. sw-launch doctor Verify host tools (cor24-run, pa24r, pl24r) found. Every flag agents used to invent is now either a TOML field or a --profile. There is no --load-binary to mistype. Phase 1: What Runs Today The Phase 1 saga (sw-launcher-phase1, ten steps) closed clean on April 28. End state: sw-launch run echo --config tests/fixtures/scenario_a/sw-launch.toml exits 0 and prints A (the captured UART output). sw-launch check echo validates the scenario without spawning the emulator. sw-launch build echo assembles every assembler-kind layer under &lt;config-dir&gt;/.sw-launch/build/&lt;scenario&gt;/&lt;layer&gt;/ with a sha256-keyed in-process memoization cache. 60 tests across 11 binaries, including 2 end-to-end against the real cor24-run binary, all green. validate.rs implements 17 stable error codes, each with a negative test asserting the exact code and span. The integration tests ran against cor24-run 0.1.0, rustc 1.94.1, edition 2024, on Darwin 24.6.0. Every one of those versions is recorded in the repo’s status.md so future-me knows what “Phase 1 worked” actually meant. Phase 2 (sw-launcher-phase2) is open and seeded with five steps; step 001 (PCode tool with SourceSpec resolution) and step 002 (cross-layer listing-symbol patches resolve at scenario validate time) just landed. Phase 2’s target is end-to-end Scenario B: COR24 runtime at 0 plus a p-code blob at a higher address with a code_ptr patch — the smallest meaningful test that the launcher can express the layered shape that today’s sw-cor24-pcode and sw-cor24-pascal demos use. Validation: Catch Collisions Before the Emulator Does The validator is the most important verb. sw-launch check reads the TOML and the lockfile, walks every segment of every layer in the scenario, computes absolute address ranges (resolving profile partitions to addresses, embedded segments through the producing layer’s listing, sidecars from disk), and runs the rules. Each rule has a stable error code so agents can match on it without scraping prose. A subset, selected for what they catch: Code Rule E0003 No two memory ranges overlap (across embedded and reserved segments). E0004 UART layers declare max_bytes and the input fits. E0005 Layer kind and load method are compatible. E0006 Every patch resolves — symbol exists, segment exists, topo order is right. E0011 Reserved stack/heap/bss lies inside regions.sram, never touches regions.ebr_stack or regions.mmio. E0014 embedded = true segments declare a symbol that resolves in the producing layer’s listing. E0023 Resident-mode mismatch (a layer claims a slot the resident shell doesn’t expose). E0028 Heap-budget overshoot (heap exceeds the profile’s heap_max). E0029 Heap &gt;= 80% of heap_max (warn). E0030 Missing heap_justification for heap &gt; 32 KiB. E0031 Layer cites a partition or region the profile does not declare. E0032 Profile self-overlap (the profile’s own partitions collide). E0033 Total claimed SRAM &gt; 1 MiB. E0034 heap_justification.category = "dead-leak" or "algorithmic-bloat" under --strict. --strict mode promotes most warnings to errors and always rejects dead-leak and algorithmic-bloat justifications. CI runs strict; local check runs lax so the shrinkage work can land incrementally without breaking the build. Caching, Vendoring, and the Lockfile The cost of not caching is that every test re-assembles pvm.s, every demo re-builds the host toolchain, every CI run wastes minutes. Phase 1 ships an in-process memoization cache (sha256 keyed on (input bytes, tool version, args, output filename)) inside the assembler tool wrapper; Phase 4 will lift that to a persistent on-disk cache at ~/.cache/sw-launch/. The key formula already accounts for the hard cases: layer_key = sha256( schema_version | normalize_toml(layer_config) | hash_each(input_files) | tool_version_hash | dependency_layer_hashes (in topo order) | resolved_address_or_uart_marker | sidecar_contents (if any) ) A cache hit is sound — if the key matches, the artifact would be byte-identical to a fresh build, so reusing it can never produce a different scenario result. Sidecar contents are in the key because v1.2’s value = "sidecar:..." patch source needs the cache to invalidate when the sidecar changes. The vendor side is symmetric. The survey was unflattering — ten of thirteen repos pin nothing at all, and one (tuplet) inherits its pins transitively from sw-cor24-ocaml. Only sw-cor24-ocaml has a real vendor/&lt;tool&gt;/&lt;version&gt;/active.env model with commit SHAs. v1.2 makes the OCaml-style vendored model the default: sw-launch vendor sync (Phase 4) will resolve declared dependencies (sibling:, vendor:, eventually git:) and write sw-launch.lock with each artifact’s commit hash and SHA. sw-launch doctor will record the observed version of each PATH-resolved tool too, so drift is visible even when nothing is explicitly pinned. What This Buys The point is not that the TOML is shorter than the shell script — it is often longer. The point is what changes about the system: One verb for agents. sw-launch run &lt;scenario&gt; replaces the ten variants of cor24-run --load-binary ... that agents kept reinventing. Validation before the emulator. The most expensive failure mode — “ran for forty seconds, traps silently, zero output” — is replaced by a fast check that names the colliding region and the offending TOML span. Heaps argue for themselves. A 252 KiB heap is not waved through; it has to declare a category, a measured floor, and a tracking issue. --strict rejects dead-leak and algorithmic-bloat outright. A schema for memory layout. Reserved heaps, embedded stacks, profile-named partitions, sidecar patches, cross-layer symbol references — all first-class in TOML. A new language port describes its memory shape; it does not write a new shell loader. Vendor versions visible. Sibling-repo dependencies are pinned by commit hash in the lockfile, not by “whatever was checked out at 3pm.” PATH-resolved tools have their observed versions recorded too. Phase 1 actually runs. The “what runs today” section is not a roadmap; it is what cargo test exercises, end-to-end, against a real emulator. The wizard metaphor still fits the post marker: thirteen repos, each with its own ring of power, all in the end answering to one. sw-launcher is the one ring in the boring sense — the one place to encode the load plan — not in the corrupting sense, hopefully. But the more accurate metaphor for v1.2 is that the schema is a budget officer: every heap that wants more than 32 KiB has to file paperwork, and the default verdict on the paperwork is “this is a leak; prove otherwise.” Where It Sits in the Personal-Software Toolkit sw-checklist, now part of the AI Tools series, sits at a different layer of the same problem: AI agents working alone produce too much variety in places where uniformity is cheaper. sw-checklist constrains the shape of the code (function/file/module/crate size limits); sw-launcher constrains the shape of the load plan and the memory budget (which addresses, which patches, how big a heap can grow before it has to file paperwork). Both are accidental complexity in Brooks’s sense. Both are paying rent. Phase 0 (survey) and Phase 1 (Scenario A end-to-end) are done. Schema v1.1 was a wrong turn that taught the right lesson: do not canonize the layout without taking a position on the budgets. Schema v1.2 takes that position, in writing, with five default profiles sized against historical implementations from the 1970s and 1980s. Phase 2 (Scenario B, runtime + p-code blob with cross-layer patches) is in flight; Phase 3 brings Scenario C (nested interpreter, heap-limit-only patches — the tuplet shape); Phase 4 brings persistent caching and vendor sync; Phase 5 brings the resident-shell composite (D + E). The wizard is just the post marker. The one ring is just the metaphor. The interesting part is the budget officer.]]></summary></entry><entry><title type="html">Dogfooding #1: YAGNI Until You Do — A Pascal P-Code Bump Allocator Grows a Free List</title><link href="https://blog.softwarewrighter.com/2026/04/27/embedded-pascal-pcode-yagni-bump-to-reclaim/" rel="alternate" type="text/html" title="Dogfooding #1: YAGNI Until You Do — A Pascal P-Code Bump Allocator Grows a Free List" /><published>2026-04-27T09:00:00-07:00</published><updated>2026-04-27T09:00:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/04/27/embedded-pascal-pcode-yagni-bump-to-reclaim</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/04/27/embedded-pascal-pcode-yagni-bump-to-reclaim/"><![CDATA[<p><img src="/assets/images/posts/block-puppy-dogfood-bowl.webp" class="post-marker no-invert" alt="" style="width: 200px;" /></p>

<div style="overflow: hidden;">

  <p>YAGNI — <em>You Ain’t Gonna Need It</em> — is one of those rules that’s easy to quote and hard to time. Skip the feature, ship the demo, move on. It works beautifully <em>until</em> the day a real workload sits down on the MVP and the missing piece is suddenly the only piece that matters. Today is that day for the Pascal p-code VM’s allocator: a bump allocator with a no-op <code class="language-plaintext highlighter-rouge">free</code> was exactly enough for every demo it ever ran, right up to the moment OCaml-in-Pascal tried to lex and parse a non-trivial Tuplet program and walked off the end of the heap.</p>

</div>

<div class="aside-box">

  <p><strong>Why Dogfooding?</strong> — The phrase comes from <em>“eating your own dog food”</em>: shipping software that you yourself rely on for real work. In this lab, dogfooding is also the <em>forcing function</em> that decides when an MVP has earned its build-out. The pattern is: implement the smallest thing that demonstrates the capability, ship a demo, move on — then wait for a downstream project to put real load on the placeholder. This series captures the moments where that load finally arrives, what was missing, and what filling the gap looked like.</p>

</div>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>COR24 Pascal toolchain</strong></td>
        <td><a href="https://github.com/sw-embed/sw-cor24-pascal">sw-embed/sw-cor24-pascal</a></td>
      </tr>
      <tr>
        <td><strong>Pascal Demo</strong></td>
        <td><a href="https://sw-embed.github.io/web-sw-cor24-pascal/">sw-embed.github.io/web-sw-cor24-pascal</a></td>
      </tr>
      <tr>
        <td><strong>Tuplet</strong></td>
        <td><a href="https://github.com/sw-vibe-coding/tuplet">sw-vibe-coding/tuplet</a></td>
      </tr>
      <tr>
        <td><strong>Related Post</strong></td>
        <td><a href="/2026/04/20/embedded-forth-self-hosting-spectrum/">Embedded #3: How Much of Forth Can Be Forth?</a></td>
      </tr>
      <tr>
        <td><strong>Related Post</strong></td>
        <td><a href="/2026/04/26/saw-tuplet-smalltalk-forth-from-forth/">Saw #8: Tuplet, Smalltalk-on-BASIC, Forth-from-Forth</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="what-the-bump-allocator-bought">What the Bump Allocator Bought</h2>

<p>The Pascal p-code VM is the runtime under <code class="language-plaintext highlighter-rouge">pa24r</code> (the Pascal compiler) and <code class="language-plaintext highlighter-rouge">pl24r</code> (the linker). Pascal source compiles to p-code; p-code executes on a small stack-and-heap VM that targets the COR24 ISA. Heap allocation, from day one, was the simplest thing that could work:</p>

<ul>
  <li>A single contiguous heap region.</li>
  <li>A bump pointer.</li>
  <li>Allocate by advancing the pointer.</li>
  <li><code class="language-plaintext highlighter-rouge">free</code> is a no-op.</li>
  <li>Out-of-heap is a hard fault.</li>
</ul>

<p>That’s it. No metadata per allocation, no headers, no free list, no compaction — just a pointer and a high-water mark. The allocator fits in a handful of p-code instructions, has zero per-allocation overhead, and is trivially correct because there is nothing to be incorrect <em>about</em>. Allocation is O(1) and the worst case equals the best case.</p>

<p>This was the right call. Every Pascal demo on COR24 to date — the BASIC interpreter, small string-handling exercises, the sample programs in the web demo — has a working-set that fits comfortably under whatever heap size the VM is configured with. Nothing on the runway needed reclaim. Building a free list would have been YAGNI-bait: more code, more bugs, more places for a wrong-looking trace to come from, all to solve a problem nobody had.</p>

<h2 id="the-forcing-function-ocaml-in-pascal-meets-tuplet">The Forcing Function: OCaml-in-Pascal Meets Tuplet</h2>

<p>The crossing-over moment came from the Tuplet front-end. <a href="https://github.com/sw-vibe-coding/tuplet">Tuplet</a> is the new glyph-and-whitespace language <a href="/2026/04/26/saw-tuplet-smalltalk-forth-from-forth/">introduced in last weekend’s Saw post</a>; its lexer and parser are written in OCaml, and OCaml itself runs — in the dogfooded version — on top of the Pascal p-code VM.</p>

<p>That stack looks like this:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Layer</th>
      <th>What’s running</th>
      <th>Implemented in</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">4</td>
      <td>Tuplet program</td>
      <td>Tuplet source</td>
    </tr>
    <tr>
      <td style="text-align: right">3</td>
      <td>Tuplet lexer/parser</td>
      <td>OCaml</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>OCaml runtime</td>
      <td>Pascal</td>
    </tr>
    <tr>
      <td style="text-align: right">1</td>
      <td>Pascal program</td>
      <td>p-code</td>
    </tr>
    <tr>
      <td style="text-align: right">0</td>
      <td>p-code VM</td>
      <td>COR24 / native host</td>
    </tr>
  </tbody>
</table>

<p>Lex-and-parse is exactly the workload a bump allocator does <em>not</em> like. Lexers churn out token objects; parsers build AST nodes by the thousand and discard most of the intermediate scaffolding (one-shot list cells, temporary closures, exception-carrying error paths). The amount of <em>live</em> data at any instant is small. The amount of <em>allocated</em> data over the lifetime of a parse is enormous. Bump allocation treats those two numbers as the same number.</p>

<h2 id="doubling-the-heap-once-is-a-fix-twice-is-a-signal">Doubling the Heap, Once Is a Fix, Twice Is a Signal</h2>

<p>For the first few inputs, the bump allocator was fine: small Tuplet sources parsed cleanly. Then the prelude grew, the test corpus grew, and parses started ending the same way — <em>out of heap</em>. The first response was the cheap one: double the heap. That bought one more input. Double again. One more input. The growth rate of the corpus was outrunning the growth rate of the heap, and every doubling pushed the failure later in the parse rather than removing it.</p>

<p>That’s the YAGNI exit signal. The next doubling wasn’t going to fix anything; it was just going to delay the same fault by another constant factor while consuming memory the host didn’t have to spare. The shape of the workload was demanding reclaim, and the allocator had to grow up.</p>

<h2 id="why-yagni-worked-and-why-it-stopped-working">Why YAGNI Worked, and Why It Stopped Working</h2>

<p>It’s worth being precise about what changed, because the instinct after a story like this is <em>“I should have built the free list from the start.”</em> That’s the wrong lesson. The bump allocator was load-bearing for almost a year of demo work, during which:</p>

<ul>
  <li>The runtime stayed small enough to read in one sitting.</li>
  <li>Memory bugs were impossible by construction (you can’t double-free a no-op).</li>
  <li>Every new Pascal feature landed against an allocator that had nothing to break.</li>
  <li>The build-out budget went into things that <em>did</em> matter for shipping demos — string handling, control flow, the p-code instruction set, the assembler, the linker.</li>
</ul>

<p>The cost of building reclaim earlier wouldn’t just have been the code; it would have been all the <em>other</em> code that didn’t get written because the free list was eating the calendar. YAGNI bought a year of velocity. It paid for itself the first time, and again the second time, and would have paid for itself again if Tuplet had stayed small.</p>

<p>What expired wasn’t the allocator’s correctness. It was the assumption that the workload-set was bounded by <em>demo</em> shapes. A self-hosted compiler front-end is not a demo; it’s a real program with its own internal allocation discipline, and that discipline assumes there’s something on the other end of the heap willing to take memory back. The first time the VM hosts that kind of workload is the first time YAGNI doesn’t apply.</p>

<h2 id="what-grow-up-looks-like">What “Grow Up” Looks Like</h2>

<div class="gutter-section">

  <p><img src="/assets/images/posts/block-jenga-tower.webp" class="gutter-img-left no-invert" alt="Jenga tower with a missing block" /></p>

  <p>The reclaim work in flight is deliberately <em>minimum-viable</em> in the same spirit as the bump allocator was — pull out the smallest piece the workload demands, leave the tower standing, and don’t add anything that doesn’t pay for itself the moment it lands. The shape:</p>

  <ul>
    <li>Add a per-allocation header carrying size and a one-bit free flag.</li>
    <li>Add an explicit <code class="language-plaintext highlighter-rouge">free(p)</code> p-code instruction that flips the flag and coalesces with adjacent free neighbors.</li>
    <li>Maintain a single free list (or a small handful of size classes — still TBD).</li>
    <li>Allocation prefers free-list reuse, falls back to bumping the high-water mark, faults only when both fail.</li>
    <li>No compaction, no GC, no tracing — the OCaml runtime knows when it’s done with a value and is willing to call <code class="language-plaintext highlighter-rouge">free</code> explicitly.</li>
  </ul>

  <p>That’s still a lot smaller than a real allocator. There’s no defragmentation, no concurrent allocation, no statistics, no debug poisoning, no quarantine. Each of those is a future YAGNI test — if the workload demands it, build it; otherwise leave it out. The new floor is <em>“reclaim works”</em>, not <em>“the allocator is finished.”</em></p>

  <p>The interesting question, once this lands, is whether the OCaml runtime’s allocation pattern is friendly enough to a free-list allocator that fragmentation never bites, or whether some later workload will bite hard enough to force compaction. I genuinely don’t know. That’s fine. The next forcing function will tell me.</p>

</div>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>A bump allocator with a no-op <code class="language-plaintext highlighter-rouge">free</code> is the right call when the workload-set is bounded by demos. It’s fast, small, and impossible to corrupt.</li>
  <li>“Real users” expire the assumption. A self-hosted compiler front-end is the smallest realistic workload that <em>requires</em> reclaim, because the working-set is small but the lifetime allocation is unbounded.</li>
  <li>Doubling the heap is a perfectly good response to a memory bug — <em>once</em>. Two doublings is a signal. Three is a confession.</li>
  <li>The next allocator should be exactly as small as the workload demands, and no smaller. Free list, header bit, coalesce on free; defer everything else until something asks for it.</li>
</ul>

<p>YAGNI didn’t fail here. It cashed out. The allocator was a placeholder all along; today is the day it was supposed to be replaced. The trick was knowing it was a placeholder, and trusting the dogfooding loop to ring the bell when the placeholder ran out.</p>

<p>Future Dogfooding posts will follow the same pattern — a placeholder that earned its keep, the workload that finally outgrew it, and what filling the gap actually looked like. Next up: a postmortem on the reclaim implementation itself, once the OCaml-on-Pascal Tuplet parse can run end-to-end without a heap fault.</p>]]></content><author><name>Software Wrighter</name></author><category term="embedded" /><category term="programming-languages" /><category term="compilers" /><category term="dogfooding" /><category term="dogfooding" /><category term="yagni" /><category term="pascal" /><category term="p-code" /><category term="vm" /><category term="allocator" /><category term="bump-allocator" /><category term="free-list" /><category term="ocaml" /><category term="tuplet" /><category term="mvp" /><category term="vibe-coding" /><summary type="html"><![CDATA[YAGNI — You Ain’t Gonna Need It — is one of those rules that’s easy to quote and hard to time. Skip the feature, ship the demo, move on. It works beautifully until the day a real workload sits down on the MVP and the missing piece is suddenly the only piece that matters. Today is that day for the Pascal p-code VM’s allocator: a bump allocator with a no-op free was exactly enough for every demo it ever ran, right up to the moment OCaml-in-Pascal tried to lex and parse a non-trivial Tuplet program and walked off the end of the heap. Why Dogfooding? — The phrase comes from “eating your own dog food”: shipping software that you yourself rely on for real work. In this lab, dogfooding is also the forcing function that decides when an MVP has earned its build-out. The pattern is: implement the smallest thing that demonstrates the capability, ship a demo, move on — then wait for a downstream project to put real load on the placeholder. This series captures the moments where that load finally arrives, what was missing, and what filling the gap looked like. Resource Link COR24 Pascal toolchain sw-embed/sw-cor24-pascal Pascal Demo sw-embed.github.io/web-sw-cor24-pascal Tuplet sw-vibe-coding/tuplet Related Post Embedded #3: How Much of Forth Can Be Forth? Related Post Saw #8: Tuplet, Smalltalk-on-BASIC, Forth-from-Forth Comments Discord What the Bump Allocator Bought The Pascal p-code VM is the runtime under pa24r (the Pascal compiler) and pl24r (the linker). Pascal source compiles to p-code; p-code executes on a small stack-and-heap VM that targets the COR24 ISA. Heap allocation, from day one, was the simplest thing that could work: A single contiguous heap region. A bump pointer. Allocate by advancing the pointer. free is a no-op. Out-of-heap is a hard fault. That’s it. No metadata per allocation, no headers, no free list, no compaction — just a pointer and a high-water mark. The allocator fits in a handful of p-code instructions, has zero per-allocation overhead, and is trivially correct because there is nothing to be incorrect about. Allocation is O(1) and the worst case equals the best case. This was the right call. Every Pascal demo on COR24 to date — the BASIC interpreter, small string-handling exercises, the sample programs in the web demo — has a working-set that fits comfortably under whatever heap size the VM is configured with. Nothing on the runway needed reclaim. Building a free list would have been YAGNI-bait: more code, more bugs, more places for a wrong-looking trace to come from, all to solve a problem nobody had. The Forcing Function: OCaml-in-Pascal Meets Tuplet The crossing-over moment came from the Tuplet front-end. Tuplet is the new glyph-and-whitespace language introduced in last weekend’s Saw post; its lexer and parser are written in OCaml, and OCaml itself runs — in the dogfooded version — on top of the Pascal p-code VM. That stack looks like this: Layer What’s running Implemented in 4 Tuplet program Tuplet source 3 Tuplet lexer/parser OCaml 2 OCaml runtime Pascal 1 Pascal program p-code 0 p-code VM COR24 / native host Lex-and-parse is exactly the workload a bump allocator does not like. Lexers churn out token objects; parsers build AST nodes by the thousand and discard most of the intermediate scaffolding (one-shot list cells, temporary closures, exception-carrying error paths). The amount of live data at any instant is small. The amount of allocated data over the lifetime of a parse is enormous. Bump allocation treats those two numbers as the same number. Doubling the Heap, Once Is a Fix, Twice Is a Signal For the first few inputs, the bump allocator was fine: small Tuplet sources parsed cleanly. Then the prelude grew, the test corpus grew, and parses started ending the same way — out of heap. The first response was the cheap one: double the heap. That bought one more input. Double again. One more input. The growth rate of the corpus was outrunning the growth rate of the heap, and every doubling pushed the failure later in the parse rather than removing it. That’s the YAGNI exit signal. The next doubling wasn’t going to fix anything; it was just going to delay the same fault by another constant factor while consuming memory the host didn’t have to spare. The shape of the workload was demanding reclaim, and the allocator had to grow up. Why YAGNI Worked, and Why It Stopped Working It’s worth being precise about what changed, because the instinct after a story like this is “I should have built the free list from the start.” That’s the wrong lesson. The bump allocator was load-bearing for almost a year of demo work, during which: The runtime stayed small enough to read in one sitting. Memory bugs were impossible by construction (you can’t double-free a no-op). Every new Pascal feature landed against an allocator that had nothing to break. The build-out budget went into things that did matter for shipping demos — string handling, control flow, the p-code instruction set, the assembler, the linker. The cost of building reclaim earlier wouldn’t just have been the code; it would have been all the other code that didn’t get written because the free list was eating the calendar. YAGNI bought a year of velocity. It paid for itself the first time, and again the second time, and would have paid for itself again if Tuplet had stayed small. What expired wasn’t the allocator’s correctness. It was the assumption that the workload-set was bounded by demo shapes. A self-hosted compiler front-end is not a demo; it’s a real program with its own internal allocation discipline, and that discipline assumes there’s something on the other end of the heap willing to take memory back. The first time the VM hosts that kind of workload is the first time YAGNI doesn’t apply. What “Grow Up” Looks Like The reclaim work in flight is deliberately minimum-viable in the same spirit as the bump allocator was — pull out the smallest piece the workload demands, leave the tower standing, and don’t add anything that doesn’t pay for itself the moment it lands. The shape: Add a per-allocation header carrying size and a one-bit free flag. Add an explicit free(p) p-code instruction that flips the flag and coalesces with adjacent free neighbors. Maintain a single free list (or a small handful of size classes — still TBD). Allocation prefers free-list reuse, falls back to bumping the high-water mark, faults only when both fail. No compaction, no GC, no tracing — the OCaml runtime knows when it’s done with a value and is willing to call free explicitly. That’s still a lot smaller than a real allocator. There’s no defragmentation, no concurrent allocation, no statistics, no debug poisoning, no quarantine. Each of those is a future YAGNI test — if the workload demands it, build it; otherwise leave it out. The new floor is “reclaim works”, not “the allocator is finished.” The interesting question, once this lands, is whether the OCaml runtime’s allocation pattern is friendly enough to a free-list allocator that fragmentation never bites, or whether some later workload will bite hard enough to force compaction. I genuinely don’t know. That’s fine. The next forcing function will tell me. Takeaways A bump allocator with a no-op free is the right call when the workload-set is bounded by demos. It’s fast, small, and impossible to corrupt. “Real users” expire the assumption. A self-hosted compiler front-end is the smallest realistic workload that requires reclaim, because the working-set is small but the lifetime allocation is unbounded. Doubling the heap is a perfectly good response to a memory bug — once. Two doublings is a signal. Three is a confession. The next allocator should be exactly as small as the workload demands, and no smaller. Free list, header bit, coalesce on free; defer everything else until something asks for it. YAGNI didn’t fail here. It cashed out. The allocator was a placeholder all along; today is the day it was supposed to be replaced. The trick was knowing it was a placeholder, and trusting the dogfooding loop to ring the bell when the placeholder ran out. Future Dogfooding posts will follow the same pattern — a placeholder that earned its keep, the workload that finally outgrew it, and what filling the gap actually looked like. Next up: a postmortem on the reclaim implementation itself, once the OCaml-on-Pascal Tuplet parse can run end-to-end without a heap fault.]]></summary></entry><entry><title type="html">AI Tools #3: sw-checklist — Reining In AI Coding Agents With a Code-Metrics Ratchet</title><link href="https://blog.softwarewrighter.com/2026/04/27/sw-checklist-ratchet-ai-coding-agents/" rel="alternate" type="text/html" title="AI Tools #3: sw-checklist — Reining In AI Coding Agents With a Code-Metrics Ratchet" /><published>2026-04-27T02:00:00-07:00</published><updated>2026-04-27T02:00:00-07:00</updated><id>https://blog.softwarewrighter.com/2026/04/27/sw-checklist-ratchet-ai-coding-agents</id><content type="html" xml:base="https://blog.softwarewrighter.com/2026/04/27/sw-checklist-ratchet-ai-coding-agents/"><![CDATA[<p><img src="/assets/images/posts/block-twisted-ropes.webp" class="post-marker no-invert" alt="" style="width: 200px;" /></p>

<div style="overflow: hidden;">

  <p>When I say “vibe-coding,” the quotes are doing real work. I am not turning the AI loose and accepting whatever lands. I am using AI agents the same way I’d use a sharp tool with a guard on it: deliberately, with constraints that are themselves additional work. The constraints are <em>accidental</em> complexity in Brooks’s sense — they come from how I choose to build, not from the problem itself — and yet I’d argue they are the only reason the code stays focused on the <em>essential</em> complexity that actually matters. <a href="https://github.com/softwarewrighter/sw-checklist">sw-checklist</a> is the personal-software tool I wrote to keep that discipline in the loop.</p>

</div>

<div class="aside-box">

  <p><strong>Why this matters</strong> — It is easy to confuse “the AI is fast” with “the AI is producing good code.” Without forcing functions — code metrics, a linter, a TDD loop — a generative agent will happily emit a 600-line file with 12 functions per module and 9 modules per crate, none of which are technically wrong, all of which are technically a mess. The interesting question is not whether to spend on accidental complexity, but <em>which</em> accidental complexity earns its keep.</p>

</div>

<div class="resource-box">

  <table>
    <thead>
      <tr>
        <th>Resource</th>
        <th>Link</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>sw-checklist</strong></td>
        <td><a href="https://github.com/softwarewrighter/sw-checklist">softwarewrighter/sw-checklist</a></td>
      </tr>
      <tr>
        <td><strong>No Silver Bullet (Wikipedia)</strong></td>
        <td><a href="https://en.wikipedia.org/wiki/No_Silver_Bullet">en.wikipedia.org/wiki/No_Silver_Bullet</a></td>
      </tr>
      <tr>
        <td><strong>Rich Hickey — Simple Made Easy</strong></td>
        <td><a href="https://www.infoq.com/presentations/Simple-Made-Easy/">infoq.com/presentations/Simple-Made-Easy</a></td>
      </tr>
      <tr>
        <td><strong>Related Personal Software post</strong></td>
        <td><a href="/2026/03/07/pjmai-rs-navigation-history-and-fuzzy-completion/">pjmai-rs: Navigation History and Fuzzy Completion</a></td>
      </tr>
      <tr>
        <td><strong>Comments</strong></td>
        <td><a href="https://discord.com/invite/Ctzk5uHggZ">Discord</a></td>
      </tr>
    </tbody>
  </table>

</div>

<h2 id="brooks-essential-vs-accidental-complexity">Brooks: Essential vs Accidental Complexity</h2>

<p>Fred Brooks’s <em>No Silver Bullet</em> (1986, later folded into the 20th-anniversary edition of <em>The Mythical Man-Month</em>) draws the line that has framed this argument for forty years:</p>

<ul>
  <li><strong>Essential complexity</strong> is the complexity inherent in the problem itself. Modeling tax law is hard because tax law is hard. There is no clever framework that erases the irregularities of the rules.</li>
  <li><strong>Accidental complexity</strong> is the complexity introduced by the tools, languages, and processes we use to attack the problem. CRUD boilerplate, build-system friction, framework idioms — none of it is part of the problem; all of it is part of the cost of solving the problem with the tools at hand.</li>
</ul>

<p>Brooks’s punchline was that decades of progress had eaten most of the <em>accidental</em> complexity (assemblers, then high-level languages, then garbage collection, then better debuggers), and that future productivity gains would have to come from attacking <em>essential</em> complexity — which is much harder, because it sits inside the problem and refuses to be abstracted away.</p>

<p>That framing still holds. What it does not say — and what is the interesting modern question — is that <em>not all accidental complexity is waste</em>. Some of it is investment. Some of it pays rent.</p>

<h2 id="hickey-simple-vs-easy-complect-vs-decomplect">Hickey: Simple vs Easy, Complect vs Decomplect</h2>

<p>Rich Hickey’s <em>Simple Made Easy</em> (Strange Loop 2011) sharpens the same axis from a different angle. Restating it briefly — and this is my paraphrase, not a quote:</p>

<ul>
  <li><strong>Simple</strong> is <em>un-complected</em>: one role, one task, one concept, not braided with anything else. The Latin root is <em>simplex</em> — one fold.</li>
  <li><strong>Easy</strong> is <em>familiar</em> and <em>near-at-hand</em>: requires little new learning, fits the muscles you already have.</li>
  <li><strong>Complex</strong> is <em>complected</em> — braided, interleaved, two or more concerns sharing a single piece of code.</li>
</ul>

<p>Hickey’s central claim is that we mistake <em>easy</em> for <em>simple</em>. Reaching for the familiar tool is easy, but it often produces complected code: classes that hold both state and identity, functions that mix decisions with effects, modules that interleave domain logic with transport. Easy now, complex later. <em>Simple</em>, by contrast, is often <em>not</em> easy — it requires more upfront thought to keep concerns separated — but the resulting code is decomplected and stays decomplected as it grows.</p>

<p>Brooks tells you <em>what kind</em> of complexity you are paying. Hickey tells you <em>how the payment compounds</em>. Together they suggest a strategy: accept some accidental cost up front if and only if the payment buys you <em>simple</em> — decomplected, single-role — code.</p>

<h2 id="the-investment-thesis">The Investment Thesis</h2>

<p>This is the move I want to defend: <em>some accidental complexity is the cheapest known way to preserve the focus on essential complexity</em>.</p>

<p>A linter is accidental. It rejects code that the language would otherwise compile. The cost is real — the AI agent burns tokens fixing line lengths, the human burns minutes reading diagnostics. The payment is that the next reader can recognize patterns instantly because the surface is uniform.</p>

<p>A TDD loop is accidental. It demands two passes for every line of behavior — the test that fails, then the code that makes it pass. The cost is doubled output. The payment is that essential changes become localized: when the model of the world is wrong, the test names tell you exactly which assumption broke.</p>

<p>Code metrics are accidental. There is nothing wrong, in the language sense, with a 600-line file or a module that holds 12 functions. The payment is that thresholds force the <em>next</em> level of decomposition — a 25-line function ceiling makes you name the sub-step; a 4-functions-per-module warning makes you ask whether two of those functions are really one thing braided with another. Hickey would call that <em>decomplecting</em>.</p>

<p>In all three cases the accidental complexity is paying rent on essential clarity.</p>

<h2 id="sw-checklist-as-a-forcing-function-on-ai-agents">sw-checklist as a Forcing Function on AI Agents</h2>

<p><a href="https://github.com/softwarewrighter/sw-checklist">sw-checklist</a> is a Rust CLI I run against a project to check conformance. It auto-detects project type — Rust crate, workspace, CLI tool, web UI — and runs the appropriate checks. The interesting checks for this post are the <em>modularity</em> ones:</p>

<table>
  <thead>
    <tr>
      <th>Check</th>
      <th style="text-align: right">Warn</th>
      <th style="text-align: right">Fail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Function lines of code</td>
      <td style="text-align: right">&gt; 25</td>
      <td style="text-align: right">&gt; 50</td>
    </tr>
    <tr>
      <td>File lines of code</td>
      <td style="text-align: right">&gt; 350</td>
      <td style="text-align: right">&gt; 500</td>
    </tr>
    <tr>
      <td>Functions per module</td>
      <td style="text-align: right">&gt; 4</td>
      <td style="text-align: right">&gt; 7</td>
    </tr>
    <tr>
      <td>Modules per crate</td>
      <td style="text-align: right">&gt; 4</td>
      <td style="text-align: right">&gt; 7</td>
    </tr>
    <tr>
      <td>Crates per project</td>
      <td style="text-align: right">&gt; 4</td>
      <td style="text-align: right">&gt; 7</td>
    </tr>
  </tbody>
</table>

<p>The numbers themselves are debatable. What is not debatable is what they do to the <em>agent’s</em> behavior over time. There are two regimes:</p>

<ul>
  <li><strong>Reactive.</strong> The agent writes whatever it wants, runs the checklist, sees a failure on “module X has 9 functions,” and rewrites to split. This costs tokens. It also produces good code — the split is often the decomplecting step that the agent would have skipped on its own.</li>
  <li><strong>Anticipatory.</strong> Eventually the agent starts splitting <em>before</em> the checklist runs. New code lands closer to the thresholds on the first pass, with named sub-modules and small functions, because the agent has internalized the constraint as a writing style rather than a post-hoc fix-up.</li>
</ul>

<p>The transition is the interesting moment. It does not happen on day one. It happens after enough cycles that the model has built up a gradient toward the shape of the constraint. From then on, the accidental cost shrinks because the agent is no longer paying for re-writes — it is writing decomplected code on the first try.</p>

<p>The linter and the TDD loop have the same shape. Each one is an accidental cost up front and a <em>style</em> eventually. Each one decomplects a different axis: the linter normalizes the surface, TDD localizes the model, sw-checklist enforces the decomposition.</p>

<h2 id="the-ratchet">The Ratchet</h2>

<div class="gutter-section">

  <p><img src="/assets/images/posts/block-ratchet-wrench.webp" class="gutter-img-left no-invert" alt="Ratchet wrench" /></p>

  <p>Tech debt grows when <em>anything</em> is allowed to slide. The naive policy — “we’ll clean it up later” — reliably produces a heap. The opposite policy — “no debt, ever” — reliably produces paralysis.</p>

  <p>The policy I aim for is a ratchet:</p>

  <ul>
    <li>The wrench turns one way.</li>
    <li>Sometimes a tooth slips back — a temporary hack, a function over the threshold, a test marked <code class="language-plaintext highlighter-rouge">ignore</code> while a bigger refactor lands.</li>
    <li>The next click <em>must</em> be forward. Slips are bounded; the trend is monotone.</li>
  </ul>

  <p>A ratchet does not promise that every commit reduces debt. It promises that the <em>cumulative</em> direction is reduction. That promise is enforceable: at every cycle, ask “is the metric better or worse than last commit?” and refuse to merge a regression unless it is consciously, visibly, paying for a larger forward turn.</p>

  <p>This is the connecting tissue between the Brooks framing and the day-to-day. If accidental complexity is investment, the ratchet is what protects the investment from being clawed back by the next sprint’s deadline.</p>

</div>

<h2 id="de-complexifying-as-a-practice">De-complexifying as a Practice</h2>

<p>Pull the threads together:</p>

<ol>
  <li>Brooks: distinguish accidental from essential complexity, and <em>spend on accidental cost</em> only where it preserves essential clarity.</li>
  <li>Hickey: aim for <em>simple</em> (decomplected), not just <em>easy</em> (familiar). The two are not the same and confusing them is how complected code grows.</li>
  <li>The investment thesis: linter, TDD, and code metrics are accidental costs that pay rent in decomplecting — they make essential complexity stay visible.</li>
  <li>The forcing function: AI agents respond to constraints first reactively, then anticipatorily. The sooner the constraint is in the loop, the sooner the agent’s output moves from messy-and-correct to focused-and-correct.</li>
  <li>The ratchet: tech debt may slip a tooth but the wrench only turns one way. Cumulative direction matters more than per-commit purity.</li>
</ol>

<p>The phrase I keep coming back to is <em>de-complexify</em>. It is not the same as <em>simplify</em>. Simplifying is reducing scope; de-complexifying is keeping the same scope while pulling the strands apart so each strand has one job. Hickey’s word for the move is <em>decomplect</em>. Brooks would call the result a code base where the essential complexity is <em>visible</em> and the accidental complexity is <em>bounded</em>.</p>

<p>Vibe-coding done well is exactly that: AI agents producing focused, decomplected code, fast, because the constraints are doing the de-complexifying work in the background and the agent has learned to write inside them.</p>

<p>The constraints cost something. They are accidental complexity. They are also the only way I have found to keep the essential complexity in view long enough to actually solve it. sw-checklist is one piece of that personal-software toolkit — a small CLI whose only job is to make the next decomplecting step impossible to ignore.</p>]]></content><author><name>Software Wrighter</name></author><category term="cli-tools" /><category term="rust" /><category term="ai-tools" /><category term="sw-checklist" /><category term="personal-software" /><category term="rust" /><category term="cli" /><category term="code-metrics" /><category term="accidental-complexity" /><category term="essential-complexity" /><category term="brooks" /><category term="mythical-man-month" /><category term="no-silver-bullet" /><category term="hickey" /><category term="simple-made-easy" /><category term="tech-debt" /><category term="ratchet" /><category term="vibe-coding" /><category term="ai-coding" /><category term="tdd" /><category term="linter" /><category term="forcing-function" /><summary type="html"><![CDATA[When I say “vibe-coding,” the quotes are doing real work. I am not turning the AI loose and accepting whatever lands. I am using AI agents the same way I’d use a sharp tool with a guard on it: deliberately, with constraints that are themselves additional work. The constraints are accidental complexity in Brooks’s sense — they come from how I choose to build, not from the problem itself — and yet I’d argue they are the only reason the code stays focused on the essential complexity that actually matters. sw-checklist is the personal-software tool I wrote to keep that discipline in the loop. Why this matters — It is easy to confuse “the AI is fast” with “the AI is producing good code.” Without forcing functions — code metrics, a linter, a TDD loop — a generative agent will happily emit a 600-line file with 12 functions per module and 9 modules per crate, none of which are technically wrong, all of which are technically a mess. The interesting question is not whether to spend on accidental complexity, but which accidental complexity earns its keep. Resource Link sw-checklist softwarewrighter/sw-checklist No Silver Bullet (Wikipedia) en.wikipedia.org/wiki/No_Silver_Bullet Rich Hickey — Simple Made Easy infoq.com/presentations/Simple-Made-Easy Related Personal Software post pjmai-rs: Navigation History and Fuzzy Completion Comments Discord Brooks: Essential vs Accidental Complexity Fred Brooks’s No Silver Bullet (1986, later folded into the 20th-anniversary edition of The Mythical Man-Month) draws the line that has framed this argument for forty years: Essential complexity is the complexity inherent in the problem itself. Modeling tax law is hard because tax law is hard. There is no clever framework that erases the irregularities of the rules. Accidental complexity is the complexity introduced by the tools, languages, and processes we use to attack the problem. CRUD boilerplate, build-system friction, framework idioms — none of it is part of the problem; all of it is part of the cost of solving the problem with the tools at hand. Brooks’s punchline was that decades of progress had eaten most of the accidental complexity (assemblers, then high-level languages, then garbage collection, then better debuggers), and that future productivity gains would have to come from attacking essential complexity — which is much harder, because it sits inside the problem and refuses to be abstracted away. That framing still holds. What it does not say — and what is the interesting modern question — is that not all accidental complexity is waste. Some of it is investment. Some of it pays rent. Hickey: Simple vs Easy, Complect vs Decomplect Rich Hickey’s Simple Made Easy (Strange Loop 2011) sharpens the same axis from a different angle. Restating it briefly — and this is my paraphrase, not a quote: Simple is un-complected: one role, one task, one concept, not braided with anything else. The Latin root is simplex — one fold. Easy is familiar and near-at-hand: requires little new learning, fits the muscles you already have. Complex is complected — braided, interleaved, two or more concerns sharing a single piece of code. Hickey’s central claim is that we mistake easy for simple. Reaching for the familiar tool is easy, but it often produces complected code: classes that hold both state and identity, functions that mix decisions with effects, modules that interleave domain logic with transport. Easy now, complex later. Simple, by contrast, is often not easy — it requires more upfront thought to keep concerns separated — but the resulting code is decomplected and stays decomplected as it grows. Brooks tells you what kind of complexity you are paying. Hickey tells you how the payment compounds. Together they suggest a strategy: accept some accidental cost up front if and only if the payment buys you simple — decomplected, single-role — code. The Investment Thesis This is the move I want to defend: some accidental complexity is the cheapest known way to preserve the focus on essential complexity. A linter is accidental. It rejects code that the language would otherwise compile. The cost is real — the AI agent burns tokens fixing line lengths, the human burns minutes reading diagnostics. The payment is that the next reader can recognize patterns instantly because the surface is uniform. A TDD loop is accidental. It demands two passes for every line of behavior — the test that fails, then the code that makes it pass. The cost is doubled output. The payment is that essential changes become localized: when the model of the world is wrong, the test names tell you exactly which assumption broke. Code metrics are accidental. There is nothing wrong, in the language sense, with a 600-line file or a module that holds 12 functions. The payment is that thresholds force the next level of decomposition — a 25-line function ceiling makes you name the sub-step; a 4-functions-per-module warning makes you ask whether two of those functions are really one thing braided with another. Hickey would call that decomplecting. In all three cases the accidental complexity is paying rent on essential clarity. sw-checklist as a Forcing Function on AI Agents sw-checklist is a Rust CLI I run against a project to check conformance. It auto-detects project type — Rust crate, workspace, CLI tool, web UI — and runs the appropriate checks. The interesting checks for this post are the modularity ones: Check Warn Fail Function lines of code &gt; 25 &gt; 50 File lines of code &gt; 350 &gt; 500 Functions per module &gt; 4 &gt; 7 Modules per crate &gt; 4 &gt; 7 Crates per project &gt; 4 &gt; 7 The numbers themselves are debatable. What is not debatable is what they do to the agent’s behavior over time. There are two regimes: Reactive. The agent writes whatever it wants, runs the checklist, sees a failure on “module X has 9 functions,” and rewrites to split. This costs tokens. It also produces good code — the split is often the decomplecting step that the agent would have skipped on its own. Anticipatory. Eventually the agent starts splitting before the checklist runs. New code lands closer to the thresholds on the first pass, with named sub-modules and small functions, because the agent has internalized the constraint as a writing style rather than a post-hoc fix-up. The transition is the interesting moment. It does not happen on day one. It happens after enough cycles that the model has built up a gradient toward the shape of the constraint. From then on, the accidental cost shrinks because the agent is no longer paying for re-writes — it is writing decomplected code on the first try. The linter and the TDD loop have the same shape. Each one is an accidental cost up front and a style eventually. Each one decomplects a different axis: the linter normalizes the surface, TDD localizes the model, sw-checklist enforces the decomposition. The Ratchet Tech debt grows when anything is allowed to slide. The naive policy — “we’ll clean it up later” — reliably produces a heap. The opposite policy — “no debt, ever” — reliably produces paralysis. The policy I aim for is a ratchet: The wrench turns one way. Sometimes a tooth slips back — a temporary hack, a function over the threshold, a test marked ignore while a bigger refactor lands. The next click must be forward. Slips are bounded; the trend is monotone. A ratchet does not promise that every commit reduces debt. It promises that the cumulative direction is reduction. That promise is enforceable: at every cycle, ask “is the metric better or worse than last commit?” and refuse to merge a regression unless it is consciously, visibly, paying for a larger forward turn. This is the connecting tissue between the Brooks framing and the day-to-day. If accidental complexity is investment, the ratchet is what protects the investment from being clawed back by the next sprint’s deadline. De-complexifying as a Practice Pull the threads together: Brooks: distinguish accidental from essential complexity, and spend on accidental cost only where it preserves essential clarity. Hickey: aim for simple (decomplected), not just easy (familiar). The two are not the same and confusing them is how complected code grows. The investment thesis: linter, TDD, and code metrics are accidental costs that pay rent in decomplecting — they make essential complexity stay visible. The forcing function: AI agents respond to constraints first reactively, then anticipatorily. The sooner the constraint is in the loop, the sooner the agent’s output moves from messy-and-correct to focused-and-correct. The ratchet: tech debt may slip a tooth but the wrench only turns one way. Cumulative direction matters more than per-commit purity. The phrase I keep coming back to is de-complexify. It is not the same as simplify. Simplifying is reducing scope; de-complexifying is keeping the same scope while pulling the strands apart so each strand has one job. Hickey’s word for the move is decomplect. Brooks would call the result a code base where the essential complexity is visible and the accidental complexity is bounded. Vibe-coding done well is exactly that: AI agents producing focused, decomplected code, fast, because the constraints are doing the de-complexifying work in the background and the agent has learned to write inside them. The constraints cost something. They are accidental complexity. They are also the only way I have found to keep the essential complexity in view long enough to actually solve it. sw-checklist is one piece of that personal-software toolkit — a small CLI whose only job is to make the next decomplecting step impossible to ignore.]]></summary></entry></feed>