<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>michaelheap.com</title>
<subtitle>Thoughts on leadership, code and how to fix odd edge cases in tools (not necessarily in that order)</subtitle>
<link href="https://michaelheap.com/rss" rel="self"/>
<link href="https://michaelheap.com"/>
<updated>2026-01-21T09:58:37Z</updated>
<id>https://michaelheap.com</id>
<author>
<name>Michael Heap</name>
<email>[email protected]</email>
</author>
<entry>
<title>Fix the inputs, not the outputs</title>
<link href="https://michaelheap.com/fix-the-inputs/"/>
<updated>2026-01-21T09:58:37Z</updated>
<id>https://michaelheap.com/fix-the-inputs/</id>
<content type="html"><p>A friend of mine is a design leader at a company with a couple of thousand people. We were recently chatting about the amount of time she was spending reviewing her team's work. It wasn't because the work was bad, but because she had no idea if the design met the needs of the project. She was spending hours trying to reverse-engineer the requirements from the final design.</p>
<p>The reviews didn't feel good for the individual designers either. Their manager was coming in at the end of a project and scrutinizing every little decision without the full context of what the requirements were. As it turned out, the designers <em>also</em> didn't have the full context - they knew how it started, but not how the requirements evolved over time. That information lived somewhere between the manager, the designers, and the project lead, with no clear source of truth.</p>
<p>My friend works in a design team, but I've seen this play out in a variety of settings. Engineers without clear product requirements. DevRel teams without clear metrics. Salespeople without a solid ideal customer profile. Anytime someone's required to fill in the blanks themselves, there's a high chance that it will lead to frustration for everyone involved.</p>
<h2 id="garbage-in%2C-garbage-out" tabindex="-1">Garbage In, Garbage Out</h2>
<p>Every output is an echo of its inputs. If the team doesn't know what &quot;good&quot; looks like from the start, they have to make decisions that they're not informed enough to make. Sometimes it works out, but more often than not it results in conflict.</p>
<p>The person reviewing the output feels that something is &quot;off&quot;, while the team is blindsided by feedback that could have been provided as a requirement early, before the work was done.</p>
<p>Good requirements aren't about more documentation - they're about building a shared understanding of what you're trying to achieve.</p>
<ul>
<li>What problem are we solving?</li>
<li>What does success look like?</li>
<li>What constraints do we have?</li>
</ul>
<p>By aligning on the answer to these questions early, you make it easy to evaluate the work objectively. Did it solve the problem within the constraints expressed? Yes? Excellent work! Let's ship it.</p>
<h2 id="a-stitch-in-time" tabindex="-1">A Stitch in Time</h2>
<p>&quot;But we don't have time to do such rigorous alignment work! They need this delivered yesterday!&quot; I hear you, I understand, and respectfully, I disagree.</p>
<p>You're already spending this time, and more, at the end of the project &quot;catching problems&quot; with the implementation. It feels productive to catch the issues, but doing it after the work is complete is both expensive and demoralizing for the team.</p>
<p>Aligning on the inputs takes time, but it takes less time than trying to reconstruct requirements from an output. It takes less time than redoing the work because someone made a decision they didn't have enough context to make. Investing the time up front pays off in the long run. <em>A stitch in time saves nine.</em></p>
<blockquote>
<p>In order to improve for good, you need to solve problems at the systems level. Fix the inputs and the outputs will fix themselves. - <strong><em>James Clear, Atomic Habits</em></strong></p>
</blockquote>
<h2 id="how-to-fix-the-inputs" tabindex="-1">How to Fix the Inputs</h2>
<p>Fixing the inputs isn't hard, but it does take some concerted effort. At your next project kickoff, try asking the initial framing questions:</p>
<ul>
<li>What problem are we solving?</li>
<li>What does success look like?</li>
<li>What constraints do we have?</li>
</ul>
<p>Take the answers provided and write them down in a location that can be linked to from the work you do. This provides the base for your requirements doc.</p>
<p>Over time, you'll build up a list of additional questions that are specific to your org, for example:</p>
<ul>
<li>Is this intended for new users or for experienced users?</li>
<li>Are they likely to interact on a small screen, or will they always be on a laptop-sized screen?</li>
<li>There's a lot of data being processed here. Are users expecting a result within a second, a minute or an hour?</li>
</ul>
<p>Of course, not every requirement can be known in advance. Discovery happens through iteration and exploration. You learn what users really need, what's technically feasible, and what trade-offs actually matter by trying things out.</p>
<p>That's normal. The problem isn't that requirements evolve, it's that the written requirements are rarely updated as the real world requirements change. The initial assumptions are treated as the &quot;requirements&quot; even as reality shifts. This is even worse than having no requirements at all, as the final output is then evaluated against incorrect assumptions.</p>
<p>When requirements change, don't delete the old requirements. Instead, append to the document and make a note of the date. Then add a &quot;current requirements&quot; section to the top that is a synthesis of everything you know from the initial definition and the updated requirements. This is your evaluation criteria once the project is ready for review.</p>
<h2 id="the-real-definition-of-%22done%22" tabindex="-1">The Real Definition of &quot;Done&quot;</h2>
<p>Everyone has a different definition of &quot;done&quot; depending on their context and point of view. When teams say a project is &quot;done&quot;, they mean that the solution is ready for review. In reality, the work is only done when everyone is aligned that this is the best possible outcome given the constraints.</p>
<p>Before calling something complete, review everyone's understanding of the problem and the proposed solution. Ask &quot;Do we still agree on what good looks like? Are the inputs still valid?&quot;</p>
<p>When inputs are solid, reviews become easier. Feedback stops becoming a surprise attack and becomes a shared evaluation against a known goal. Teams spend less time justifying their decisions and more time improving the product. &quot;Done&quot; stops meaning &quot;I hope this is what you wanted&quot; and starts meaning &quot;we all understand why this is the best solution given the trade-offs&quot;.</p>
<p>So the next time you're tempted to try and reverse engineer what was required from the final output (and inevitably provide misguided feedback), pause and think about what led you to this point.</p>
<p>Don't fix the output.</p>
<p>Fix the input.</p>
</content>
</entry>
<entry>
<title>When Asking is Easy (and Answering Isn't)</title>
<link href="https://michaelheap.com/asymmetric-questions/"/>
<updated>2026-01-14T08:34:05Z</updated>
<id>https://michaelheap.com/asymmetric-questions/</id>
<content type="html"><p>It's 4pm on a Thursday, and I'm patting myself on the back that I've managed to align multiple teams on a tricky project. The requirements document has been reviewed and approved by everyone involved, and I'm excited to see what's been scoped come to life.</p>
<p>I'm about to sign off for the day when someone from our SaaS team pinged me: &quot;Hey, quick question. What would it take to make this multi-tenant?&quot;. It's as though the walls come crashing down around me. It's not a quick question at all - it's one that takes deep technical, historical, and organizational knowledge to unpack.</p>
<p>I sigh as I make another cup of tea and settle in to write an essay of a response, trying to balance the need for details without it appearing as though I'm trying to overwhelm them with complex explanations.</p>
<p>Questions like this aren't malicious. They're often great questions, asked in good faith. But they reveal something important about effort - it's much easier to ask questions than to answer them.</p>
<h2 id="asymmetric-questions" tabindex="-1">Asymmetric Questions</h2>
<blockquote>
<p>An asymmetric question is one where the effort to ask is trivial compared to the effort to answer.</p>
</blockquote>
<p>This asymmetry isn't inherently bad. I'd choose to work with a curious team over a disinterested team every time given a choice. But when left unchecked, these questions can lead to a high emotional load for those receiving them.</p>
<p>I've been the person receiving the questions, and my responses have ranged from &quot;Why are they asking questions that they definitely won't understand the answer to?&quot; (which was an unkind categorization on my behalf) to &quot;This sucks. Weeks of alignment undone by a single question. Maybe I can pretend I didn't see it?&quot; to &quot;This is a good question. I should have covered this in the first place. It's going to take time, but it's worthwhile&quot;.</p>
<p>Lack of knowledge is often the driver of asymmetric questions. The person asking is often missing the context required to realize how much effort it will take to answer what appears to be a simple question.</p>
<p>In general, the more senior or cross-functional your audience, the higher the likelihood is of one of them asking an asymmetric question. This isn't malicious - their curiosity spans domains that they're not totally familiar with.</p>
<p>Asymmetric questions can be valuable, but they can also be distracting. If the system that the asker is operating in rewards visibility, you're going to get spontaneous questions that don't add much. They don't care as much about the answer as they do about being seen to contribute. Over time, this erodes focus and encourages reactive rather than strategic work.</p>
<h2 id="managing-the-imbalance" tabindex="-1">Managing the Imbalance</h2>
<p>Asymmetric questions aren't malicious. They're born from curiosity, but left unchecked they can command a lot of time. It takes 30 seconds to ask a question, and 30 minutes to answer it.</p>
<p>My rule of thumb for asymmetric questions is to spend twice as long answering a question as it took to ask it. If they spent a minute asking a question, I'll give it two minutes of thought then respond (usually to ask clarifying questions of my own). The asker's response might take 5 minutes to write, and so I'll take 10 minutes to respond.</p>
<p>If a question takes one minute to write and will take twenty minutes to respond to, schedule a call. That's a good sign that there's something missing from either the asker's knowledge, or from your proposal itself.</p>
<p>The goal isn't to stop people asking questions. It's to right-size your response.</p>
<h2 id="navigating-asymmetric-questions" tabindex="-1">Navigating Asymmetric Questions</h2>
<p>The first step to navigating asymmetric questions successfully is to get past the resentment stage as quickly as possible. Even being aware of the knowledge asymmetry, I still have an initial burst of resentment every time someone asks &quot;why don't we do X?&quot;.</p>
<p>The impact that their question has on the project, and on me as an individual, is huge. But I need to separate intent from impact. People don't generally ask asymmetric questions without a reason.</p>
<p>Once you're past the initial resentment, you can engage with the asker in good faith. Many people see a question and feel like they have to answer it immediately, and in depth. This usually isn't the case.</p>
<h3 id="exploring-or-deciding" tabindex="-1">Exploring or Deciding</h3>
<p>Questions on proposals are either <em>exploring</em> or <em>deciding</em>.</p>
<p>If someone is <em>exploring</em> an idea e.g. &quot;Did we consider internationalizing the docs?&quot; then you can respond with a similar level of depth. You could say &quot;We did, but the return on investment for both time and cost wasn't there. I'm happy to dig in more if you like.&quot;</p>
<p>If the same question is phrased slightly differently, they're looking for a <em>decision</em>: &quot;Why aren't we internationalizing the docs?&quot;. My personal instinct here is to write a 10 paragraph response in the comments section of a Google doc, but that's the wrong approach. Instead, propose a follow-up with the correct audience: &quot;That's a good question. A full answer will take some time to put together. Can we chat next week with a few others that were involved in the decision?&quot;</p>
<h3 id="clarify-and-redirect" tabindex="-1">Clarify and Redirect</h3>
<p>If the question is open-ended, don't try to answer every potential question. Here's one question that I had recently:</p>
<blockquote>
<p>Why don't we provide a hosted version of {Dependency X}?</p>
</blockquote>
<p>Instead of diving in, I asked a clarifying question to narrow the scope: &quot;There a couple of reasons. Are you asking about technical, cost or market reasons?&quot;. The author came back with &quot;market&quot; and I explained that a hosted version would be well received by the market, but the technical implications (single tenant dependency) meant that the cost of providing a service (COGS) was too high for it to be a viable option for us.</p>
<p>Asking questions turns what sounds like a demand into a conversation. Either the asker wants to really dig in and you can have a fruitful conversation, or (as I've experienced many times) they just don't respond at all and you can forget about the question entirely.</p>
<h2 id="answer-with-intention" tabindex="-1">Answer with Intention</h2>
<p>Asymmetric questions are inevitable in collaborative work, especially as scope and seniority increases. By clarifying the questions and matching your effort to their intent you can keep projects moving without letting &quot;one quick question&quot; derail weeks of progress.</p>
</content>
</entry>
<entry>
<title>Visibility is Velocity</title>
<link href="https://michaelheap.com/visibility-is-velocity/"/>
<updated>2026-01-07T11:29:57Z</updated>
<id>https://michaelheap.com/visibility-is-velocity/</id>
<content type="html"><p>Once upon a time, I worked with a Product Manager, Bob. Bob's team worked with incoming data pipelines, and transformed and augmented a variety of disparate data sources in to a consistent data model. It was a tough job, as you didn't actually know what data you were going to receive from our providers until it arrived.</p>
<p>Bob’s team also had a reputation for being <em>slow</em>. On average, onboarding a new data source took about three months. When the work shipped, though, it was excellent: reliable, well-instrumented, and easy to maintain.</p>
<p>But when they shipped, they didn't get any accolades. The team heard &quot;Finally! Why did it take so long?&quot;. Worse, they heard &quot;I wish you'd have warned us - we haven't implemented the new data source in the billing system&quot;. So not only did the project <em>feel</em> late, the business couldn’t even use what had been delivered.</p>
<p>A few months later, Bob was no longer with the company. The feedback was that he “wasn’t compatible with how the business needed to operate.” We hired Alice as his replacement.</p>
<p>The perceived change was immediate. Alice shipped something in her first two weeks. People were impressed. It didn’t even seem plausible that Bob’s team could ship <em>anything</em> in two weeks, let alone within two weeks of a new PM starting.</p>
<p>But Alice didn’t <em>actually</em> make the team faster. New data sources still took three months to fully integrate. The work was the same. The complexity was the same. What changed was that progress became visible.</p>
<p>And suddenly, everyone believed the team had become fast.</p>
<h2 id="the-dangers-of-silence" tabindex="-1">The Dangers of Silence</h2>
<p>Humans hate ambiguity. When there’s no signal, people invent their own story.</p>
<p>Bob's lack of updates created its own narrative: the work had stalled, or it wasn’t a priority. The project lost momentum. Other teams postponed their related work. Leaders delayed decisions tied to the project. Everyone just kind of &quot;forgot&quot; about the ongoing work.</p>
<p>The lack of communication usually comes from a good place:</p>
<blockquote>
<p>“I didn’t want to distract people until I had something solid.”</p>
</blockquote>
<p>But updates aren't a distraction. Surprises are. And surprises are expensive for everyone.</p>
<p>Each and every time I've found myself surprising an executive, I've ended up on calls where they're trying to figure out what's going on. Those calls take a <em>lot</em> of time.</p>
<p>We need to re-establish context for those that aren't deep in the project, then explain what's happening and why. The conversation inevitably turns to re-litigation of a decision that was already made.</p>
<p>Before you know it, the engineering team is frustrated that we're revisiting old decisions and the execs are frustrated that things are taking even longer. Surprises don't serve anyone.</p>
<h2 id="the-power-of-updates" tabindex="-1">The Power of Updates</h2>
<p>Sending regular updates doesn't make you go any faster - in fact, it slows you down slightly as writing the updates takes time. You don't want them to be a torrent of useless information (&quot;We added 14 new tests and added support for the FlubJam&quot;). Instead, focus on what's useful (&quot;We added tests to ensure that URL mapping works consistently, and integrated FlubJam so that the billing team can get started with their work&quot;).</p>
<p>These small, regular updates build trust and predictability for your audience. They know what's happening, and that the project is still on track. And if there <em>is</em> something that isn't quite right, these regular updates allows people to raise concerns, build alignment and course correct while the cost of doing so is cheap.</p>
<h2 id="how-to-ship-incrementally" tabindex="-1">How to Ship Incrementally</h2>
<p>Let's go back to Bob and Alice. Alice didn’t deliver results any faster. She just made progress visible.</p>
<p>Instead of waiting for everything to be solid, she shared updates like these:</p>
<ul>
<li>Week 2: Great news! We've connected to the third party system. Here's a video of us ingesting a piece of data (no mapping, no augmentation)</li>
<li>Week 4: We've figured out what data is available. Here's a link to our proposed data mapping, and a demo video with the mapping added</li>
<li>Week 6: The pipeline is running well, and we've managed to add some augmentations. Here's a video of URL extraction from text working. Look how it augments the data with the page title from the URL</li>
<li>Week 8: Slow progress this week. We realised that the data isn't guaranteed to arrive in order, so it turns out that we were missing some data. We're reworking our pipeline to use the deduplication service so that we don't have to rely on timestamps. I'm still optimistic that we'll be done next month</li>
<li>Week 10: All solved! I've shared the data size/volume information with the billing team so that they can load it into the rate card system. We're working on additional test cases to give us confidence that we haven't missed anything.</li>
<li>Week 12: Tada! The project is done. Here's a list of sample configurations and test cases that we run. Thank you for all the feedback along the way.</li>
</ul>
<p>We still couldn't ship the new data source to customers until week 12, but all of the stakeholders were clear what was happening every step of the way.</p>
<p>Compare that to Bob's updates:</p>
<ul>
<li>Week 12: The integration is complete. Here’s the final implementation - let me know if you have feedback.</li>
</ul>
<p>The engineering team did the same work on the same timeline, but the leadership team had a <em>very different perception</em>.</p>
<p>Alice wasn’t seen as faster because she did more. She was seen as faster because progress showed up continuously. Risks were surfaced early, course corrections happened when needed, and no one was surprised at the end.</p>
<p>Your updates don’t need to be long. Fifteen minutes to produce. Two minutes to consume. That's all it takes.</p>
</content>
</entry>
<entry>
<title>Bulk unsubscribe in Gmail</title>
<link href="https://michaelheap.com/gmail-unsubscribe/"/>
<updated>2026-01-06T10:23:42Z</updated>
<id>https://michaelheap.com/gmail-unsubscribe/</id>
<content type="html"><p>Back in 2021 I added an idea to <a href="https://michaelheap.com/ideas">/ideas</a> that would scan my email inbox and provide a single page containing unsubscribe links that have been scraped from emails. Here was the proposal:</p>
<blockquote>
<p>A UI that reads all emails in a folder and shows all emails with an <code>unsubscribe</code> link.<br />
Provide a way to group by sender / subject / unsubscribe URL<br />
I'd like to be able to choose which folder this runs in (minimum Inbox + Trash, but ideally any folder/label)</p>
</blockquote>
<p>Today I learned that Google have built this themselves. In your inbox, click on <code>More</code> in the sidebar and then choose <code>Manage subscriptions</code>. You'll see a screen that shows you email volume from each sender and allows you to unsubscribe with a single click.</p>
<div class="image-wrapper "><picture> <source class="" type="image/webp" srcset="https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-320.webp 320w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-640.webp 640w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-960.webp 960w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-1200.webp 1200w" sizes="(max-width: 320px) 320px, (max-width: 640px) 640px, (max-width: 960px) 960px, (max-width: 1200px) 1200px, 100vw" /> <img class="" alt="Gmail Manage Subscriptions page screenshot" src="https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-1200.jpeg" sizes="(max-width: 320px) 320px, (max-width: 640px) 640px, (max-width: 960px) 960px, (max-width: 1200px) 1200px, 100vw" srcset="https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-320.jpeg 320w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-640.jpeg 640w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-960.jpeg 960w, https://michaelheap.com/images/gmail-unsubscribe/manage-subscriptions.png/TLIW-f1pvP-1200.jpeg 1200w" width="1200" height="592" /> </picture></div>
<p>Now, I need to go and unsubscribe from all of these Kickstarter platform emails that I always delete without reading...</p>
</content>
</entry>
<entry>
<title>Building `listentotaxman-cli` with OpenCode + Claude Sonnet 4.5</title>
<link href="https://michaelheap.com/listentotaxman-cli/"/>
<updated>2026-01-05T16:01:31Z</updated>
<id>https://michaelheap.com/listentotaxman-cli/</id>
<content type="html"><p>If you've ever needed to calculate tax in the UK, you've probably landed on <a href="https://listentotaxman.com/">https://listentotaxman.com/</a>. My main use case is comparing two sets of numbers - sometimes it's base salary, sometimes it's to see what happens if I change a pension contribution %.</p>
<p>The website is ok, but I wanted something I could script. So this morning, I <a href="https://github.com/mheap/listentotaxman-cli?tab=readme-ov-file#usage">wrapped their API in a CLI</a>. Or rather, I asked Copilot / Claude Sonnet 4.5 to wrap their API while I did some other work.</p>
<p>This idea has been on my <a href="https://michaelheap.com/ideas">/ideas</a> page since September 2021, and I've never taken the time to build it out. Occasionally I'll use the website and remember that I wanted to build a CLI but then I solve my immediate problem and move on.</p>
<p>Today, I decided that this was a nice, bounded problem that I could use to test out <code>OpenCode</code>, which was recommended to me over the weekend as a vendor neutral Claude Code replacement.</p>
<h2 id="building-with-opencode-%2B-copilot" tabindex="-1">Building with OpenCode + Copilot</h2>
<p>Here's the transcript of the conversation I had with OpenCode. I've skipped over some of the clarifying questions that were inconsequential (e.g. when it asked if responses should be cached, which ASCII table library I prefer etc). I've added a <code>clarification</code> section when it asked good questions, and <code>insight</code> for when I was pleasantly surprised when the LLM reminded me about actions that I needed to take.</p>
<p>As with when I was building <a href="https://michaelheap.com/gh-saved-issues/">gh-saved-issues</a> I did some initial exploration so that I could provide <code>curl</code> commands to get the project started:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>You are building a Cobra based Golang CLI for <a href="https://listentotaxman.com/">https://listentotaxman.com/</a>. A sample curl request for calling the API is:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">curl </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">https://listentotaxman.com/ws/tax/index.js.php</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> --data-raw </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">{"response":"json","year":"2025","taxregion":"uk","age":"0","pension":"3%","time":"1","grosswage":100000}</span><span style="color: #ECEFF4">'</span></div></code></div></pre>
<p>Build a CLI that can be invoked as:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">listentotaxman check --year 2025 --region uk --age 0 --pension 3% --grosswage 100000 --student-loan postgraduate --extra 999 --tax-code K12</span></div></code></div></pre>
<p>The --tax-code flag should be passed as &quot;taxcode&quot; in the API request<br />
The --student-loan flag should be passed as &quot;plan&quot; in the API request<br />
The --region flag should be passed as &quot;taxregion&quot; in the API request</p>
<p>Year should default to the current year (e.g. 2026) if today's date is after the fifth of april. Otherwise it should default to the previous year.</p>
<p>--tax-region should default to &quot;uk&quot;</p>
</div>
</div>
<p>The LLM one-shot this pretty well. I could run <code>listentotaxman check</code> and see the output:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="code-container"><code><div class="line"><span style="color: undefined">╔══════════════════════════════════════════════╗</span></div><div class="line"><span style="color: undefined">║ Tax Calculation for 2025 (UK) - Yearly ║</span></div><div class="line"><span style="color: undefined">╠══════════════════════════════════════════════╣</span></div><div class="line"><span style="color: undefined">║ Gross Salary £100,000.00 ║</span></div><div class="line"><span style="color: undefined">║ Taxable Pay £97,129.00 ║</span></div><div class="line"><span style="color: undefined">║ Tax Paid £31,311.60 ║</span></div><div class="line"><span style="color: undefined">║ National Insurance £4,010.60 ║</span></div><div class="line"><span style="color: undefined">║ Student Loan £4,740.00 ║</span></div><div class="line"><span style="color: undefined">║ Pension (You) £3,000.00 ║</span></div><div class="line"><span style="color: undefined">║ Net Pay £56,937.80 ║</span></div><div class="line"><span style="color: undefined">╠══════════════════════════════════════════════╣</span></div><div class="line"><span style="color: undefined">║ Employer's NI £14,250.00 ║</span></div><div class="line"><span style="color: undefined">║ Pension (HMRC) £2,000.00 ║</span></div><div class="line"><span style="color: undefined">║ Total Cost £116,250.00 ║</span></div><div class="line"><span style="color: undefined">╚══════════════════════════════════════════════╝</span></div></code></div></pre>
<p>One of the things I liked about the website is the ability to see these figures for a specific time period:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Add a --period flag that defaults to &quot;yearly&quot;. Other possible options are &quot;monthly&quot; &quot;weekly&quot; &quot;daily&quot; &quot;hourly&quot;. Take the &quot;yearly&quot; values and divide by the 12 (monthly), 52 (weekly) or 365 (daily) or 2920 (hourly)</p>
</div>
</div>
<p>Did you spot my mistake? The LLM did:</p>
<div class="ai-input">
<div class="ai-header">
❓ Clarification
</div>
<div class="ai-body">
<p>Question: Is 2920 correct for hourly? This assumes 8-hour workdays. Some alternatives:</p>
<p>2080 hours (40 hours/week × 52 weeks) - standard US calculation<br />
1820 hours (typical UK full-time: 37.5 hours/week × 52 weeks - 5 weeks holiday)<br />
2920 hours (365 × 8 hours) - your specified value</p>
</div>
</div>
<p>I opted to use 2080 hours, assuming that I get paid for my lunchtime too.</p>
<p>At this point the CLI was working well, but there were some formatting issues due to the recent changes:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Alignment is off in the header:</p>
<p>══════════════════════════════════════════════╗<br />
║ Tax Calculation for 2025 (UK) - Monthly║<br />
╠══════════════════════════════════════════════╣</p>
</div>
</div>
<p>With the basic usage working, it was time to add the <code>compare</code> command that the UI doesn't provide. The trickiest thing to figure out here was how to accept groups of information.</p>
<p>I ended up accepting the pattern <code>--option &lt;NAME&gt; --flags go --here</code>, where any flags provided after one <code>--option</code> value, but before another are treated as one set of configuration:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Now add a new <code>listentotaxman compare</code> command. Accept all flags from <code>listentotaxman check</code></p>
<p>Each flag can be provided multiple times. Each group of inputs should be preceeded by an <code>--option</code> flag e.g.:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">listentotaxman compare --option job1 --pension 3% --grosswage 10 --option job2 --grosswage 1000 --pension 10%</span></div></code></div></pre>
</div>
</div>
<p>Once the command was built, I ran <code>listentotaxman compare --help</code> to see how to use it, but the help screen was useless:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Improve the --help output. The following is not useful:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">Error: no options specified </span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">use --option to define each scenario</span><span style="color: #ECEFF4">)</span></div><div class="line"><span style="color: #D8DEE9FF">Usage:</span></div><div class="line"><span style="color: #D8DEE9FF">listentotaxman compare</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">Flags:</span></div><div class="line"><span style="color: #D8DEE9FF">-h, --help </span><span style="color: #88C0D0">help</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">for</span><span style="color: #D8DEE9FF"> compare</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF">Error: no options specified </span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">use --option to define each scenario</span><span style="color: #ECEFF4">)</span></div></code></div></pre>
</div>
</div>
<p>The LLM updated the <code>--help</code> command with <a href="https://github.com/mheap/listentotaxman-cli/blob/32e8fd38ec1e16689537196b405e6ff08c38e007/cmd/compare.go#L48-L72">many useful examples</a>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">listentotaxman compare \</span></div><div class="line"><span style="color: #D8DEE9FF"> --option </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Current Job</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> --income 100000 --pension 3% \</span></div><div class="line"><span style="color: #D8DEE9FF"> --option </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">New Offer</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF"> --income 120000 --pension 5%</span></div></code></div></pre>
<p>The CLI did everything I needed at this point. Everything that I did next was just polishing the experience:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Format numbers using the correct locale e.g. 100,000 rather than 100000</p>
</div>
</div>
<p>I realsied that I only provided sample API calls, and we should support everything the UI does. I've removed the HTML from the following prompt, but assume that I pasted in the whole HTML form from the UI:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Here is the HTML definition for the inputs available on the website. Which CLI flags are we missing?</p>
<p>...snip...</p>
</div>
</div>
<p>It was interesting for me to see the LLM match the UI properties to the form:</p>
<div class="ai-input">
<div class="ai-header">
👀 Insight
</div>
<div class="ai-body">
<p>Implemented ✅<br />
--year - Tax year<br />
--region - Tax region (uk, scotland, wales, ni)<br />
--grosswage - Salary amount<br />
--pension - Pension contribution (£ or %)<br />
--student-loan (maps to plan) - Student loan plan<br />
--age - Age selection<br />
--tax-code (maps to code) - Tax code<br />
--extra (maps to add) - Additional allowances/deductions<br />
Missing Flags 🔴<br />
Based on the HTML form, we're missing these inputs:</p>
<p>--married (checkbox: married)</p>
<p>Boolean flag for married status<br />
Affects marriage allowance calculations<br />
--blind (checkbox: blind)</p>
<p>Boolean flag for blind person's allowance<br />
Additional tax-free allowance for registered blind persons<br />
--no-ni or --exempt-ni** (checkbox: exNI)</p>
<p>Boolean flag for &quot;I pay no NI&quot;<br />
For people exempt from National Insurance<br />
--partner-wage (input: partnerGrossWage)</p>
<p>Partner's gross wage for marriage allowance calculations<br />
Only relevant when married flag is set<br />
Affects transferable marriage allowance<br />
--time - Input frequency selector</p>
<p>Currently we only accept yearly amounts<br />
Website supports: year, month, 4 weeks, 2 weeks, week, day, hour<br />
Note: This is an INPUT multiplier, different from our --period OUTPUT divisor<br />
Additional Observations<br />
Student Loan Plans<br />
We support:</p>
<p>✅ plan1<br />
✅ plan2<br />
✅ postgraduate<br />
We're missing:</p>
<p>🔴 plan4 - Plan 4 (for students from Scotland)<br />
🔴 scottish - Scottish student loan<br />
Age Values<br />
Current implementation uses string values like &quot;0&quot;, &quot;1&quot;, &quot;2&quot;, &quot;3&quot;</p>
<p>Website uses:</p>
<p>0 - under 65 ✅<br />
3 - female 60-65 (historical, likely for older tax years) 🔴<br />
1 - 65-74 ✅<br />
2 - over 75 ✅<br />
We're mostly correct but missing the &quot;female 60-65&quot; option.</p>
<p>Region Values<br />
Our values match except:</p>
<p>We use: uk, scotland, wales, ni ✅<br />
Website uses: uk, scotland, eng, wales, ni<br />
Minor difference: We don't have eng (England) as distinct from uk 🟡</p>
</div>
</div>
<p>I had the LLM implement all of the above flags and options (and add them to <code>--help</code>), then continued to polish the experience:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Rename --grosswage to --income</p>
</div>
</div>
<p>Finally, it was time to release. I knew I wanted to use GitHub Actions and <code>goreleaser</code>:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>I want to build and release this tool with github actions and goreleaser. Build a plan to add this CI/CD setup</p>
</div>
</div>
<p>The LLM wanted to do so much more! It suggested enabling CodeQL, dependabot, Homebrew support and Docker builds.</p>
<div class="ai-input">
<div class="ai-header">
❓ Clarification
</div>
<div class="ai-body">
<p>Please answer the questions above, and I'll create:</p>
<p>Minimal setup (if you want quick start):</p>
<ul>
<li>.goreleaser.yml with basic config</li>
<li>.github/workflows/release.yml for releases</li>
<li>.github/workflows/ci.yml for testing</li>
</ul>
<p>Full-featured setup (if you want everything):</p>
<ul>
<li>All workflows (release, CI, CodeQL, dependabot)</li>
<li>Homebrew tap support</li>
<li>Docker image builds</li>
<li>Comprehensive documentation</li>
<li>Security scanning</li>
</ul>
<p>Which approach do you prefer? And please let me know your answers to the questions above!</p>
</div>
</div>
<p>I chose an option that it didn't give me: the minimal setup, but with Homebrew/Docker support too.</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Option 3: Minimal setup, but with Homebrew / Docker support</p>
</div>
</div>
<p>This is when the LLM surprised me. In addition to generating all of the workflows, it succinctly explained which secrets I needed to add to my repository, and how to find those values:</p>
<div class="ai-input">
<div class="ai-header">
👀 Insight
</div>
<div class="ai-body">
<p>🔧 Manual Setup Steps Checklist<br />
Before you can release, complete these manual steps:</p>
<p>✅ Step 1: Create Docker Hub Access Token<br />
Go to <a href="https://hub.docker.com/settings/security">https://hub.docker.com/settings/security</a><br />
Click &quot;New Access Token&quot;<br />
Description: &quot;GoReleaser CI/CD&quot;<br />
Access permissions: &quot;Read, Write, Delete&quot;<br />
Generate and COPY the token (shown only once)</p>
<p>✅ Step 2: Create GitHub Personal Access Token<br />
Go to <a href="https://github.com/settings/tokens">https://github.com/settings/tokens</a><br />
Click &quot;Generate new token (classic)&quot;<br />
Name: &quot;GoReleaser Homebrew Tap&quot;<br />
Expiration: No expiration (or as preferred)<br />
Scopes: Check repo (all sub-scopes)<br />
Generate and COPY the token</p>
<p>✅ Step 3: Add Secrets to GitHub Repository<br />
Navigate to: <a href="https://github.com/mheap/listentotaxman-cli/settings/secrets/actions">https://github.com/mheap/listentotaxman-cli/settings/secrets/actions</a></p>
<p>Click &quot;New repository secret&quot; for each:</p>
<p>Secret 1: GH_PAT</p>
<p>Name: GH_PAT<br />
Value: (GitHub PAT from Step 2)<br />
Secret 2: DOCKERHUB_USERNAME</p>
<p>Name: DOCKERHUB_USERNAME<br />
Value: mheap<br />
Secret 3: DOCKERHUB_TOKEN</p>
<p>Name: DOCKERHUB_TOKEN<br />
Value: (Docker Hub token from Step 1)</p>
<p>✅ Step 4: Configure GitHub Actions Permissions<br />
Go to: <a href="https://github.com/mheap/listentotaxman-cli/settings/actions">https://github.com/mheap/listentotaxman-cli/settings/actions</a><br />
Under &quot;Workflow permissions&quot;:<br />
Select &quot;Read and write permissions&quot;<br />
Check &quot;Allow GitHub Actions to create and approve pull requests&quot;<br />
Click &quot;Save&quot;</p>
<p>✅ Step 5: Verify Homebrew Tap Repository<br />
Confirm <a href="https://github.com/mheap/homebrew-tap">https://github.com/mheap/homebrew-tap</a> exists and is:</p>
<p>Public<br />
Empty (or has minimal content - GoReleaser will manage it)</p>
</div>
</div>
<p>I didn't like that I had to provide permissions via settings. I want them declared in the workflow instead:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Update the GitHub Actions workflows to set permissions explicitly</p>
</div>
</div>
<p>And finally, it wouldn't be a GitHub Actions implementation unless the first build failed. The LLM somehow generated a version 2 configuration file for <code>goreleaser</code> while using version 1 in CI. I pasted the error in and it fixed it with no intervention from me:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>only configurations files on version: 1 are supported, yours is version: 2, please update your configuration<br />
Error: The process '/opt/hostedtoolcache/goreleaser-action/1.26.2/x64/goreleaser' failed with exit code 1</p>
</div>
</div>
<p>I pushed these changes to the repo, tagged a new release and watched as the <code>listentotaxman</code> CLI came alive! I ran <code>brew install mheap/tap/listentotaxman</code> on my local machine and now I can compare tax implications as much as I want 😁.</p>
</content>
</entry>
<entry>
<title>Electron Node module version</title>
<link href="https://michaelheap.com/electron-node-module-version/"/>
<updated>2025-12-27T17:11:26Z</updated>
<id>https://michaelheap.com/electron-node-module-version/</id>
<content type="html"><p>I've been building an Electron app for the first time. It was all going well until I tried to use a dependency that involved native components. When I tried to run the app, I got an error that <code>[...] was compiled against a different Node.js version</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">Error occurred </span><span style="color: #81A1C1">in</span><span style="color: #D8DEE9FF"> handler </span><span style="color: #81A1C1">for</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">tasks:fetch</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF">: Error: The module </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">/private/tmp/electron-demo/node_modules/better-sqlite3/build/Release/better_sqlite3.node</span><span style="color: #ECEFF4">'</span></div><div class="line"><span style="color: #D8DEE9FF">was compiled against a different Node.js version using</span></div><div class="line"><span style="color: #D8DEE9FF">NODE_MODULE_VERSION 137. This version of Node.js requires</span></div><div class="line"><span style="color: #D8DEE9FF">NODE_MODULE_VERSION 140. Please try re-compiling or re-installing</span></div><div class="line"><span style="color: #D8DEE9FF">the module </span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">for instance, using </span><span style="color: #ECEFF4">`</span><span style="color: #A3BE8C">npm rebuild</span><span style="color: #ECEFF4">`</span><span style="color: #D8DEE9FF"> or </span><span style="color: #ECEFF4">`</span><span style="color: #A3BE8C">npm install</span><span style="color: #ECEFF4">`</span><span style="color: #ECEFF4">)</span><span style="color: #D8DEE9FF">.</span></div></code></div></pre>
<p>It turns out that the node.js and Electron ABIs are not compatible (see the <a href="https://github.com/nodejs/node/blob/main/doc/abi_version_registry.json">compatibility matrix</a>), and any native code needs to be compiled specifically for Electron.</p>
<p>To solve the issue above, you have to rebuild native modules for Electron using <code>@electron/rebuild</code>:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">npm install --save-dev @electron/rebuild</span></div><div class="line"><span style="color: #D8DEE9FF">./node_modules/.bin/electron-rebuild</span></div></code></div></pre>
<p>Then <code>npm start</code> and everything works.</p>
<blockquote>
<p>⚠️ You'll need to re-run <code>electron-rebuild</code> after every <code>npm install</code>.</p>
</blockquote>
<h2 id="reproduction-case" tabindex="-1">Reproduction case</h2>
<p>If you're looking for a simple reproduction case:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">git clone https://github.com/mheap/electron-build-sample</span></div><div class="line"><span style="color: #88C0D0">cd</span><span style="color: #D8DEE9FF"> electron-build-sample</span></div><div class="line"><span style="color: #D8DEE9FF">npm install</span></div><div class="line"><span style="color: #D8DEE9FF">npm start </span><span style="color: #616E88"># You'll get an error here</span></div></code></div></pre>
<p>Rebuild the native modules and try again:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">./node_modules/.bin/electron-rebuild</span></div><div class="line"><span style="color: #D8DEE9FF">npm start</span></div></code></div></pre>
<p>You'll see a task that says &quot;Do the thing&quot;.</p>
</content>
</entry>
<entry>
<title>Toolsmiths melt snowflakes</title>
<link href="https://michaelheap.com/toolsmiths-melt-snowflakes/"/>
<updated>2025-12-17T09:30:00Z</updated>
<id>https://michaelheap.com/toolsmiths-melt-snowflakes/</id>
<content type="html"><p>Every company has snowflakes. Each one unique, delicate, and guaranteed to melt under pressure.</p>
<p>They accumulate quietly: the one-off script someone runs before every deploy, the spreadsheet with its own arcane formula logic, the onboarding flow that requires five Slack DMs to complete. Over time, those snowflake processes pile up into a blizzard of operational friction.</p>
<p>Toolsmiths are the ones who melt them.</p>
<p>They’re not the loudest people in the room. You won’t see them pitching product roadmaps or leading all-hands. But they build the quiet infrastructure that keeps a company from freezing in place. A toolsmith sees repetition and decides it shouldn’t exist. They build a small script, an automation, a workflow, and suddenly something that was fragile becomes reusable. That’s the kind of leverage that scales an organization, yet it’s often invisible.</p>
<h2 id="from-infrastructure-to-everywhere" tabindex="-1">From Infrastructure to Everywhere</h2>
<p>The idea of codifying process isn’t new. The DevOps movement was built on it. Chef, Ansible, and later Terraform turned one-off, bespoke configurations into repeatable, version-controlled artifacts. Infrastructure stopped being artisanal; it became declarative.</p>
<p>But somewhere along the way, the rest of the business got left behind. Marketing teams still handcraft campaigns in silos. Support teams dig through logs by memory. BI teams spend days reconciling slightly different versions of the truth in spreadsheets. Every team has its own snowflakes, and very few have the tools (or the permission) to melt them.</p>
<p>If your business isn’t like this, congratulations! You’ve invested in systems and they’re paying off. Sadly though, many businesses haven’t (engineering teams included) and they’re just one typo away from a meltdown.</p>
<h2 id="the-small-tools-that-matter" tabindex="-1">The Small Tools That Matter</h2>
<p>A few years ago, I built a simple CLI tool for GitHub. Its only job was to <a href="https://github.com/mheap/pin-github-action">pin every GitHub Action to a specific SHA</a>. That’s it. It took an afternoon to write.</p>
<p>Before that, every repository had workflows that pulled the latest version of an action. This was fine, until one day an upstream change broke half our builds. The “fix” was manual: open each repo, edit the workflow to pin to a specific commit hash, commit, push. Tedious, error-prone, and repeated by multiple teams.</p>
<p>So I wrote a CLI that scanned all repos in our org and pinned each action automatically. One run. Hundreds of snowflakes gone.</p>
<p>No one outside engineering noticed. But internally, it saved dozens of hours and reduced risk every time someone added a new workflow.</p>
<h2 id="toolsmiths-aren%E2%80%99t-just-developers" tabindex="-1">Toolsmiths Aren’t Just Developers</h2>
<p><strong>Toolsmithing is a mindset, not a job title.</strong> It’s what happens when someone sees a messy process and refuses to accept it as normal.</p>
<ul>
<li>In BI, it’s the analyst who turns scattered spreadsheets into a shared dashboard that updates automatically.</li>
<li>In Support, it’s the agent who builds a small search interface over logs so others don’t need to memorize file paths or grep commands.</li>
<li>In Partnerships, it’s the manager who realises that every customer onboarding ends up being unique, and builds a form or workflow that standardizes it.</li>
</ul>
<p>These people often work in isolation because organizations don’t recognize what they’re doing as engineering. But it <em>is</em> engineering - just applied to people, processes, and information ins tead of servers and code.</p>
<h2 id="start-small%2C-then-scale" tabindex="-1">Start Small, Then Scale</h2>
<p>There’s a myth that internal tools need to be fully featured products. That mindset kills most automation before it starts. A wiki page of useful commands is a great first tool. A small script that one team uses can later become an API or a workflow in Slack.</p>
<p>The question isn’t “Can we build this perfectly?” It’s “Is this pain common enough to solve once and share?”</p>
<p>Too many companies overengineer the idea of tooling. They spin up projects, assign teams, and stall under the weight of process. But the best tools start informally. They’re acts of curiosity, not mandates. And once proven useful, they can be hardened, shared, and maintained.</p>
<h2 id="build-without-overbuilding" tabindex="-1">Build Without Overbuilding</h2>
<p>Code is a liability, and not every solution needs it. Some of the best internal tools aren’t codebases at all. They’re Zapier automations, Airtable forms, or Slack workflows. Platforms like Retool and Node-RED exist precisely to help non-developers build lightweight, domain-specific tools.</p>
<p>But even there, restraint matters. Internal tools don’t need to impress anyone; they need to save time. David Tuite calls this out in his essay on “<a href="https://www.davidtuite.com/hidden-const-mediocre-internal-tools/">the hidden cost of mediocre internal tools</a>”: if a tool is 80% right and saves hours a week, that’s success. Perfection is the enemy of progress.</p>
<h2 id="the-cultural-blind-spot" tabindex="-1">The Cultural Blind Spot</h2>
<p>Here’s the uncomfortable part: most companies don’t reward toolsmiths. They celebrate heroics instead. Everyone hears about the engineer who pulls an all-nighter, the support rep who clears a backlog manually. But <a href="https://sre.google/resources/practices-and-processes/no-heroes/">we don’t need any more superheroes</a>.</p>
<p>A toolsmith’s work is quieter. It’s the absence of pain, not the spectacle of solving it. But that quiet efficiency compounds. Each script, dashboard, and workflow removes another point of friction - another snowflake. Over time, it transforms the company’s metabolism.</p>
<p>The irony is that the companies best at building products for others are often the worst at building tools for themselves.</p>
<h2 id="melting-snowflakes-at-scale" tabindex="-1">Melting Snowflakes at Scale</h2>
<p>Empowering toolsmiths doesn’t mean spinning up a new “internal tools” department. It means creating permission structures that let anyone automate what hurts. It means treating those efforts as strategic, not peripheral. Give them time to do the work, and access to the systems they need.</p>
<p>Imagine if every engineer, analyst, and operations specialist had an hour a week to improve their own workflow. Imagine if they had access to shared infrastructure. A data warehouse, a Retool instance, a place to share scripts safely. The compound effect would dwarf most process initiatives.</p>
<p>That’s how you grow fast without breaking things: you melt snowflakes before they become avalanches.</p>
<h2 id="build-systems%2C-not-empires" tabindex="-1">Build Systems, not Empires</h2>
<p>Organizations love to talk about scale, but scale doesn’t come from adding headcount. It comes from eliminating friction. Toolsmiths are the people who make that possible.</p>
<p>If you want to grow, don’t just hire more hands. Empower the ones you have to build better tools. Give them permission to tinker. Recognise their work.</p>
<p>Because every snowflake you melt makes your company a little stronger.</p>
</content>
</entry>
<entry>
<title>Parse Codex `jsonl` with `jq`</title>
<link href="https://michaelheap.com/extract-codex-conversation/"/>
<updated>2025-12-12T16:27:46Z</updated>
<id>https://michaelheap.com/extract-codex-conversation/</id>
<content type="html"><p>I recently <a href="https://michaelheap.com/gh-saved-issues/">built a GitHub extension</a> using ChatGPT Codex, and I wanted to share the prompts that I gave to Codex to build the tool.</p>
<p>I thought about copy/pasting it out of the VSCode UI, but figured that the data must be available somewhere. It turns out that Codex stores sessions as <code>.jsonl</code> files in <code>.codex/sessions</code>.</p>
<p>Looking through the session files, there's a ton of data in there around what the LLM does, but I was only interested in messages that I sent. Fortunately each user message has <code>type: user_message</code>.</p>
<p>Here's a one liner that uses <code>jq</code> to output all of the user messages for a given session:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">cat .codex/sessions/2025/12/04/rollout-2025-12-04T22-06-28-019aeb67-0620-71c1-8cbb-756bc8845c6e.jsonl </span><span style="color: #81A1C1">|</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> jq -r </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">. |</span></div><div class="line"><span style="color: #A3BE8C"> select(.payload.type == "user_message") |</span></div><div class="line"><span style="color: #A3BE8C"> .payload.message |</span></div><div class="line"><span style="color: #A3BE8C"> split("\n## My request for Codex:\n")[1] + "\n\n--- NEW COMMAND ---\n\n"</span><span style="color: #ECEFF4">'</span></div></code></div></pre>
</content>
</entry>
<entry>
<title>Building `gh-saved-issues` using ChatGPT Codex</title>
<link href="https://michaelheap.com/gh-saved-issues/"/>
<updated>2025-12-12T07:59:44Z</updated>
<id>https://michaelheap.com/gh-saved-issues/</id>
<content type="html"><p>I spend a <em>lot</em> of time on the <code>/issues</code> page on GitHub. Working across multiple repos in multiple orgs, it can be difficult to keep up to date on what's happening. <code>/issues</code> allows me to see everything on a single page.</p>
<p>The <code>/issues</code> page allows you to create saved searches and view them when needed. Here are a couple of examples that I use every day:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># My PRs that are awaiting review</span></div><div class="line"><span style="color: #D8DEE9FF">is:pr author:@me state:open org:Kong -draft:true</span></div></code></div></pre>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Issues open on my personal projects (excluding dependency updates + my blog)</span></div><div class="line"><span style="color: #D8DEE9FF">state:open archived:false sort:updated-desc user:mheap -author:app/dependabot -repo:mheap/michaelheap.com is:issue</span></div></code></div></pre>
<p>I also started out with a &quot;all PRs awaiting review from me filter&quot;:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># Awaiting review from me within the Kong org</span></div><div class="line"><span style="color: #D8DEE9FF">is:pr state:open org:Kong review-requested:@me</span></div></code></div></pre>
<p>However, I found that I wasn't looking at it as there were regularly too many items to review in a single session. So instead, I started segmenting my reviews by context:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #616E88"># All Terraform PRs</span></div><div class="line"><span style="color: #D8DEE9FF">is:pr state:open -author:app/renovate </span><span style="color: #ECEFF4">(</span><span style="color: #D8DEE9FF">repo:Kong/terraform-provider-konnect OR repo:Kong/terraform-provider-konnect-beta OR repo:Kong/terraform-provider-kong-gateway</span><span style="color: #ECEFF4">)</span></div></code></div></pre>
<p>This worked much better, but then I realised that I'd need to keep 90% of the search consistent, but change the repos being shown for various different contexts.</p>
<p>So I did what anyone would - I started reverse engineering the GraphQL queries needed to configure saved searches and building a configuration file with template support.</p>
<p>If you're just looking for the tool, you can find it at <a href="https://github.com/mheap/gh-saved-issues">mheap/gh-saved-issues</a>.</p>
<p>This tool is my first attempt at building something with an agent. Keep reading to see my experience.</p>
<h3 id="building-with-codex" tabindex="-1">Building with Codex</h3>
<p>I did some research before prompting Codex, including extracting <code>curl</code> commands from developer tools. I pasted these commands into my initial prompt</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>You are an expert Go developer. You have been tasked to build a GitHub CLI extension to configure saved searches on GitHub. The CLI layer should be very thin, and all of the logic for working with the API must be included in library files.</p>
<p>The tool should accept a configuration file (located at $XDG_CONFIG_HOME/.github-searches.yaml) in the following format:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #8FBCBB">searches</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Assigned to me (Kong)</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">state:open archived:false assignee:@me sort:updated-desc org:Kong</span><span style="color: #ECEFF4">"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Assigned to Steve (All Orgs)</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">state:open archived:false assignee:steve.doe sort:updated-desc</span><span style="color: #ECEFF4">"</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Work from Alice</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">template</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">recent-work</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">vars</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">user</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">alice.jones</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">time</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">7d</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Work from Bob</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">template</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">recent-work</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">vars</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">user</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">bob.smith</span></div><div class="line"><span style="color: #8FBCBB">templates</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">recent-work</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">org:Kong ((assignee:{{ user }} AND is:issue) OR (is:pr AND author:{{ user }})) (is:open OR updated:&gt;@today-{{ default(time, </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">30d") }}) sort:updated-desc"</span></div></code></div></pre>
<p>If the search has an <code>id</code> associated with it, update the existing rule. Otherwise create a new one then update the configuration file to set the <code>id</code> value.</p>
<p>To create a new saved issue search, make the following request (but use Go, don't shell out to curl):</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">curl </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">https://github.com/_graphql</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> --data-raw </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">{"query":"c06c5627e09922bd28c6d34ff91d0530","variables":{"input":{"color":"GRAY","icon":"BOOKMARK","name":"Untitled view","query":"","searchType":"ISSUES"}}}</span><span style="color: #ECEFF4">'</span></div></code></div></pre>
<p>To update a search, use this request (shortcutId is the <code>id</code> from the config):</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">curl </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">https://github.com/_graphql</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> --data-raw </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">{"query":"379dbe4cf68c3485e48df2f699f5ae75","variables":{"input":{"color":"GRAY","description":"Test","icon":"BOOKMARK","name":"Demo123","query":"user:mheap","scopingRepository":null,"shortcutId":"SSC_kgDOAB3rrw"}}}</span><span style="color: #ECEFF4">'</span></div></code></div></pre>
<p>If a user sets <code>remove: true</code> on a query, you can delete the search using the following request:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF">curl </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">https://github.com/_graphql</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF"> \</span></div><div class="line"><span style="color: #D8DEE9FF"> --data-raw </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">{"query":"2939ea7192de2c6284da481de6737322","variables":{"input":{"shortcutId":"SSC_kgDOAB3rsg"}}}</span><span style="color: #ECEFF4">'</span></div></code></div></pre>
<p>Build the CLI, and also bootstrap test cases for all library code.</p>
</div>
</div>
<p>At this point most of the work was done, except it didn't work. This graphql endpoint is not accessible using a GitHub token, it needs a cookie session to be specified</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>In addition to <code>req.Header.Set(&quot;Authorization&quot;, &quot;Bearer &quot;+c.token)</code>, allow me to specify a cookie like:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">bash</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF"> -b </span><span style="color: #ECEFF4">'</span><span style="color: #A3BE8C">_device_id=ID_HERE; user_session=SESSION_HERE</span><span style="color: #ECEFF4">'</span><span style="color: #D8DEE9FF"> \</span></div></code></div></pre>
</div>
</div>
<p>This made the API requests work, but I realised that updating an existing config wasn't working due to a bug in findShortcutId.</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Fix findShortcutId</p>
<p>The response looks like the following:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">json</div><div class="code-container"><code><div class="line"><span style="color: #ECEFF4">{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">data</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">createDashboardSearchShortcut</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">dashboard</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">shortcuts</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">totalCount</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #B48EAD">4</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">nodes</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:[{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">SSC_kgDOAA2otQ</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">mheap: Open Issues</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">state:open archived:false sort:updated-desc user:mheap -author:app/dependabot -repo:mheap/michaelheap.com is:issue </span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">icon</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">BOOKMARK</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">color</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">GRAY</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">description</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">""</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">scopingRepository</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #81A1C1">null</span><span style="color: #ECEFF4">},{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">SSC_kgDOABdOWA</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Kong: My Open PRs</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">is:pr author:@me state:open org:Kong -draft:true</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">icon</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">BOOKMARK</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">color</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">GRAY</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">description</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">""</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">scopingRepository</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #81A1C1">null</span><span style="color: #ECEFF4">},{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">SSC_kgDOABdOZw</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">Kong: Awaiting Review from Me</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">is:pr state:open org:Kong review-requested:@me -repo:Kong/platform-api </span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">icon</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">BOOKMARK</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">color</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">GRAY</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">description</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">""</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">scopingRepository</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #81A1C1">null</span><span style="color: #ECEFF4">},{</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">SSC_kgDOABdOaA</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">mheap: Awaiting Review from Me</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">state:open archived:false sort:updated-desc user:mheap -author:app/dependabot -repo:mheap/michaelheap.com is:pr </span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">icon</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">BOOKMARK</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">color</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">GRAY</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">description</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #ECEFF4">""</span><span style="color: #ECEFF4">,</span><span style="color: #ECEFF4">"</span><span style="color: #8FBCBB">scopingRepository</span><span style="color: #ECEFF4">"</span><span style="color: #ECEFF4">:</span><span style="color: #81A1C1">null</span><span style="color: #ECEFF4">}</span><span style="color: #D8DEE9">}</span></div></code></div></pre>
<p>Search for &quot;name: Kong: My Open PRs&quot; and extact the <code>id</code> field from that value</p>
</div>
</div>
<p>This worked, but was inefficient. Codex walked every node in the tree looking for IDs.</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>You don't have to walk the tree. The data is always in data.createDashboardSearchShortcut.dashboard.shortcuts.nodes</p>
</div>
</div>
<p>Success! Now let's add some new capabilities</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Now group the searches by <code>section</code>. For each section, create an empty query with the title &quot;== $SECTION ==&quot; above the list of items</p>
</div>
</div>
<p>This worked, but didn't feel right to me.</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Actually, I changed my mind. Don't use <code>section</code> in a config. Instead, sections will be defined explicitly e.g.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #8FBCBB">searches</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">section</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Demo</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">SSC_kgDOAB5MiA</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Test 1</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">state:open archived:false assignee:@me sort:updated-desc org:Kong</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">template</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">""</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">vars</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">{}</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">remove</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">false</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">SSC_kgDOAB5MiQ</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Work from Alice</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">""</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">template</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">recent-work</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">vars</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">time</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">30d</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">user</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">alice.jones</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">remove</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">false</span></div><div class="line"><span style="color: #8FBCBB">templates</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">recent-work</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">org:Kong ((assignee:{{ user }} AND is:issue) OR (is:pr AND author:{{ user }})) (is:open OR updated:&gt;@today-{{ default(time, "30d") }}) sort:updated-desc</span></div></code></div></pre>
</div>
</div>
<p>Much better! But my section configs were being given a <code>query</code> entry unexpectedly in the updated config like this:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">SSC_kgDOAB5MiA</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">section</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Demo</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">""</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">""</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">template</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">""</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">vars</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">{}</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">remove</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">false</span></div></code></div></pre>
<p>I wanted it to look like this:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">id</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">SSC_kgDOAB5MiA</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">section</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Demo</span></div></code></div></pre>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Do not replace section configs with a full query object. It should contain only <code>id</code> and <code>section</code></p>
</div>
</div>
<p>Everything looked good at this point. I wanted to try recreating everything so that the order in my config file is respected (right now, the section is the last item as it's the most recently created)</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Add a --force flag to the CLI that deletes all existing configs and recreates them, even if an ID is set</p>
</div>
</div>
<p>It worked, but I realised that the flag name should really be <code>--recreate</code>.</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Rename the force flag to --recreate</p>
</div>
</div>
<p>How about use cases where I want to remove all saved searches without recreating them?</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Also add a --reset flag that removes all entries without creating anything</p>
</div>
</div>
<p>At this point, the CLI was finished. It can create saved searches, with section markers and recreate all saved searches to enforce ordering.</p>
<p>But I wasn't done. I had an idea for another template, but needed a way to provide multiple repos as a list:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Add support for the join function in templates. Example usage:</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">name</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">Terraform PRs</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">template</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">repo-prs</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">vars</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">repos</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">repo:Kong/terraform-provider-konnect</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">repo:Kong/terraform-provider-konnect-beta</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #ECEFF4">-</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">repo:Kong/terraform-provider-kong-gateway</span></div></code></div></pre>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">yaml</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">repo-prs</span><span style="color: #ECEFF4">:</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">query</span><span style="color: #ECEFF4">:</span><span style="color: #D8DEE9FF"> </span><span style="color: #A3BE8C">is:pr state:open ({{ join(repos, "OR") }}) -author:app/renovate draft:false {{ additional }}</span></div></code></div></pre>
</div>
</div>
<p>Uh oh! There's a bug. I used <code>{{ additional }}</code> in the template to add additional filters using a free-form string. If there are no additional parameters, I expected it to resolve to an empty string (meaning &quot;no additional filters&quot;).</p>
<p>However, if a config didn't specify an empty string we got an error. I pasted the error message into Codex to try and fix it:</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>template: query:1: function &quot;additional&quot; not defined - can the template code treat missing values as null?</p>
</div>
</div>
<p>All working as expected, but how do people know how to use it?</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Produce a <a href="http://readme.md/">README.md</a> teaching people how to use this tool</p>
</div>
</div>
<p>The README mentions a <code>--config</code> flag, but it doesn't exist. Sounds like a good idea to me</p>
<div class="ai-input">
<div class="ai-header">
📝 Prompt
</div>
<div class="ai-body">
<p>Implement the --config flag like the README indicates</p>
</div>
</div>
<p>At this point, I had something I wanted to ship 🎉</p>
<h3 id="reflection" tabindex="-1">Reflection</h3>
<p>Honestly, I had a great time building this tool. I'm not sure it was faster than writing it by hand, but I did it in the background while doing something else and it ended up saving me a ton of time. If I had to write it by hand, I probably wouldn't have finished this project.</p>
<p>I <em>did</em> do a little bit extra by hand after the session above. I added a GitHub Action to build and release the extension, which means you can run it with <code>gh extension install https://github.com/mheap/gh-saved-issues</code>.</p>
</content>
</entry>
<entry>
<title>Display mirroring with Hammerspoon</title>
<link href="https://michaelheap.com/toggle-mirroring-hammerspoon/"/>
<updated>2025-12-08T13:59:48Z</updated>
<id>https://michaelheap.com/toggle-mirroring-hammerspoon/</id>
<content type="html"><p>I need to mirror my screens periodically - enough that it's painful to go in to system settings each time and configure mirroring.</p>
<p>Here's a Hammerspoon snippet that will toggle mirroring. It works because <code>allScreens</code> returns a single value if your second screen is mirroring the first.</p>
<p><code>C34H89x</code> and <code>LC49G95T</code> are my screen names, which I looked up in system settings.</p>
<pre class="shiki nord" style="background-color: #2e3440ff; color: #d8dee9ff"><div class="language-id">lua</div><div class="code-container"><code><div class="line"><span style="color: #D8DEE9">hs</span><span style="color: #D8DEE9FF">.</span><span style="color: #D8DEE9">hotkey</span><span style="color: #D8DEE9FF">.</span><span style="color: #88C0D0">bind</span><span style="color: #D8DEE9FF">({ </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">cmd</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF">, </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">alt</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF">, </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">ctrl</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF">, </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">shift</span><span style="color: #ECEFF4">" </span><span style="color: #D8DEE9FF">}, </span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">M</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF">, </span><span style="color: #81A1C1">function</span><span style="color: #ECEFF4">()</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">local</span><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">screens</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">hs</span><span style="color: #D8DEE9FF">.</span><span style="color: #D8DEE9">screen</span><span style="color: #D8DEE9FF">.</span><span style="color: #88C0D0">allScreens</span><span style="color: #D8DEE9FF">()</span></div><div class="line"></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">if</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">#</span><span style="color: #D8DEE9">screens</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">==</span><span style="color: #D8DEE9FF"> </span><span style="color: #B48EAD">1</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">then</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">screens</span><span style="color: #D8DEE9FF">[</span><span style="color: #B48EAD">1</span><span style="color: #D8DEE9FF">]:</span><span style="color: #88C0D0">mirrorStop</span><span style="color: #D8DEE9FF">()</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">else</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">local</span><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">screenToMirror</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">hs</span><span style="color: #D8DEE9FF">.</span><span style="color: #D8DEE9">screen</span><span style="color: #D8DEE9FF">.</span><span style="color: #88C0D0">find</span><span style="color: #D8DEE9FF">(</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">C34H89x</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF">)</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">local</span><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">screenToHide</span><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">=</span><span style="color: #D8DEE9FF"> </span><span style="color: #D8DEE9">hs</span><span style="color: #D8DEE9FF">.</span><span style="color: #D8DEE9">screen</span><span style="color: #D8DEE9FF">.</span><span style="color: #88C0D0">find</span><span style="color: #D8DEE9FF">(</span><span style="color: #ECEFF4">"</span><span style="color: #A3BE8C">LC49G95T</span><span style="color: #ECEFF4">"</span><span style="color: #D8DEE9FF">)</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #8FBCBB">screenToHide</span><span style="color: #D8DEE9FF">:</span><span style="color: #88C0D0">mirrorOf</span><span style="color: #D8DEE9FF">(</span><span style="color: #D8DEE9">screenToMirror</span><span style="color: #D8DEE9FF">)</span></div><div class="line"><span style="color: #D8DEE9FF"> </span><span style="color: #81A1C1">end</span></div><div class="line"><span style="color: #81A1C1">end</span><span style="color: #D8DEE9FF">)</span></div></code></div></pre>
</content>
</entry>
</feed>