<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 98dkcc488b-glitch</title>
    <description>The latest articles on DEV Community by 98dkcc488b-glitch (@98dkcc488bglitch).</description>
    <link>https://dev.to/98dkcc488bglitch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4185383%2F04730c00-c1a8-46e6-aa75-8b5e3f934d90.png</url>
      <title>DEV Community: 98dkcc488b-glitch</title>
      <link>https://dev.to/98dkcc488bglitch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/98dkcc488bglitch"/>
    <language>en</language>
    <item>
      <title>How to check if ChatGPT, Gemini, Claude and Perplexity mention your brand (free Python script)</title>
      <dc:creator>98dkcc488b-glitch</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:50:37 +0000</pubDate>
      <link>https://dev.to/98dkcc488bglitch/how-to-check-if-chatgpt-gemini-claude-and-perplexity-mention-your-brand-free-python-script-1890</link>
      <guid>https://dev.to/98dkcc488bglitch/how-to-check-if-chatgpt-gemini-claude-and-perplexity-mention-your-brand-free-python-script-1890</guid>
      <description>&lt;p&gt;Last week, a founder in a Discord asked me a question I didn't have a good answer to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I spend 6 hours a week updating meta descriptions. How do I know ChatGPT even sees my site when someone asks it about my category?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair question. I didn't know either. So I spent a weekend on it, and here is what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual question is simpler than the industry makes it
&lt;/h2&gt;

&lt;p&gt;Every "AI visibility" vendor pitches a different thing. Some count citations. Some count mentions. Some run thousands of prompts and chart the trend. Pricing starts at $29/mo and goes north of $500/mo.&lt;/p&gt;

&lt;p&gt;Under the marketing, there are really three things you can measure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the model mention your brand at all&lt;/strong&gt; when asked a category question?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In what position&lt;/strong&gt; does it mention you (first, deep in a list, as an afterthought)?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who else gets named&lt;/strong&gt;, and more often?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is it. If your audit tells you more than that, read it carefully — a lot of "signals" are inferences on top of these three facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  A script that just does the three
&lt;/h2&gt;

&lt;p&gt;I wrote this to answer my own question. It is 300 lines of Python, zero dependencies beyond &lt;code&gt;urllib&lt;/code&gt; and a Gemini API key (free tier).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask_gemini&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent?key=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}]}],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;generationConfig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thinkingConfig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thinkingBudget&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                                 &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha I lost 40 minutes to:&lt;/strong&gt; &lt;code&gt;thinkingBudget: 0&lt;/code&gt; is not optional. Without it, Gemini 2.5 Flash spends its budget on hidden reasoning and returns a 100-character stump instead of the actual answer. First time I ran five prompts and got nothing but headings.&lt;/p&gt;

&lt;p&gt;Then a small analyzer that reads the answer looking for your brand name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_brand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;competitors&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text_l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;mentions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text_l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;competitors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;mentions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text_l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="c1"&gt;# Position: first heading or top-level numbered item containing brand
&lt;/span&gt;    &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;headings_and_top_level_lists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mentions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mentions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;position&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ugly part is &lt;code&gt;headings_and_top_level_lists&lt;/code&gt;. My first version counted every bullet, including nested ones under a competitor. Linear showed up at "position 1" because it was nested under &lt;em&gt;"Jira is the leader, and alternatives include..."&lt;/em&gt;. Not what you want.&lt;/p&gt;

&lt;p&gt;Fix: only count headings and top-level numbered items. Skip nested bullets. Skip emphasis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three competitors look like at a glance
&lt;/h2&gt;

&lt;p&gt;I checked four tools last week while researching. Here is how they split the market:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;What they do differently&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Profound&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$500+/mo (as of Sept 2026, starter plans discontinued)&lt;/td&gt;
&lt;td&gt;Enterprise dashboards, citation graphs, team seats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Peec AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;€199/mo&lt;/td&gt;
&lt;td&gt;Daily tracking, 4+ engines, European billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Otterly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$29/mo&lt;/td&gt;
&lt;td&gt;ChatGPT, Claude, Perplexity (no Gemini)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Findora&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;varies&lt;/td&gt;
&lt;td&gt;Public-page audit + answer evidence labels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My own script is the fifth entry on this list, and I will be honest about where it sits: it only talks to Gemini (the free API tier), it does not have a dashboard, and it runs weekly on a cron, not daily. For a founder spending $0 to get a weekly signal, that may be enough. For a marketing team, Otterly or Peec is the serious answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the weekly report actually looks like
&lt;/h2&gt;

&lt;p&gt;The report is a static HTML file (24 KB), generated by a GitHub Action. Structure I settled on after a few iterations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Week of 2026-W43

Summary
  5/5 prompts mentioned your brand
  Average position: 1.2
  Top competitors mentioned: 3

Prompt 1: "best project management tool for small teams"
  Position 1 (headline)
  Also mentioned: Asana, Jira, Trello
  [full answer]

Prompt 2: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing fancy. The point is you open an email on Monday, see "0/5 last week, 3/5 this week" at a glance, and decide whether to spend the hour on a landing page or a product change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode of these tools (including mine)
&lt;/h2&gt;

&lt;p&gt;Gemini (and the others) is not deterministic. The same prompt returns slightly different wording each time. One week your brand is at position 1, the next week position 3, next week position 1 again, and nothing changed on your site.&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;do not act on a single week&lt;/strong&gt;. Look at a 4-week rolling trend, or run the same prompt 3 times and take the median position.&lt;/p&gt;

&lt;p&gt;I wrote this in the FAQ because it is the thing I would want someone to tell me before I bought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code and config
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/98dkcc488b-glitch/askvisible" rel="noopener noreferrer"&gt;github.com/98dkcc488b-glitch/askvisible&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/weekly.yml&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;  &lt;span class="c1"&gt;# Monday 07:00 UTC&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-python@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;python-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.11"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python run.py&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GEMINI_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fork, add your &lt;code&gt;config.json&lt;/code&gt;, commit, and the first report lands in your &lt;code&gt;reports/&lt;/code&gt; folder on Monday.&lt;/p&gt;

&lt;p&gt;If you want the all-in-one package (config template, GitHub Action, email delivery via SMTP action, sample report), there is a $19 lifetime download — same code, just packaged. Build it yourself from the repo if you prefer.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Honest note about this post:&lt;/strong&gt; I wrote it with Claude's help. The code and the methodology are mine, built and tested on a real Gemini key against a real brand. Posting it on dev.to because this is the audience that will tell me if the position-detection logic is wrong, and that is more valuable than any marketing channel I could buy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aeo</category>
      <category>aiseo</category>
      <category>python</category>
      <category>seo</category>
    </item>
    <item>
      <title>Weekend build: a $19 self-hosted AI visibility tracker (one Python file + GitHub Actions)</title>
      <dc:creator>98dkcc488b-glitch</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:16:41 +0000</pubDate>
      <link>https://dev.to/98dkcc488bglitch/weekend-build-a-19-self-hosted-ai-visibility-tracker-one-python-file-github-actions-jk9</link>
      <guid>https://dev.to/98dkcc488bglitch/weekend-build-a-19-self-hosted-ai-visibility-tracker-one-python-file-github-actions-jk9</guid>
      <description>&lt;p&gt;I kept hearing "SEO is dead, AI tells your prospects what to buy" and kept wondering what Gemini actually says when someone in my niche asks "best X in 2026". Checking by hand on a Monday morning is annoying. The tools that automate this — Profound, Peec, Otterly — start at $29/month and go up to $500+.&lt;/p&gt;

&lt;p&gt;I just wanted a number in my inbox. So I spent a weekend on the smallest possible version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One Python file, standard library only, no &lt;code&gt;pip install&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One GitHub Actions workflow, cron every Monday 08:00 UTC.&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;config.json&lt;/code&gt; with your brand, up to 10 competitors, and 5 prompts.&lt;/li&gt;
&lt;li&gt;Your free Google Gemini API key as a GitHub secret.&lt;/li&gt;
&lt;li&gt;Monday morning: an HTML report with mention check, position, competitor overlap, snippet, full answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire product. I'm charging $19 one-time for the ZIP, MIT source. No SaaS, no subscription, no dashboard — the report lives in your repo artifacts for free week-over-week comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real run looks like
&lt;/h2&gt;

&lt;p&gt;Running the tool against &lt;code&gt;Linear&lt;/code&gt; with Jira, Asana, ClickUp as competitors this week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"What are the best alternatives to Jira in 2026?"&lt;/em&gt; → Linear mentioned 5×, position 1 in the list.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Which issue trackers do YC startups commonly use?"&lt;/em&gt; → Linear mentioned 5×, position 1.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Compare the top 5 project management tools for engineers."&lt;/em&gt; → Linear mentioned 4×, position 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a 3-line signal I can glance at over coffee. If Linear fell from position 1 to position 6 next week, I'd know. Sample report is in the repo under &lt;code&gt;samples/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why only Gemini and not ChatGPT, Claude, Perplexity
&lt;/h2&gt;

&lt;p&gt;Those cost real money per API call. A $19 one-time product can't honestly bundle $0.01/call APIs — one big customer wipes out the margin. Google Gemini's free tier is generous enough (1,500 requests/day) that this tool runs on $0 forever.&lt;/p&gt;

&lt;p&gt;Gemini also powers Google's AI Overviews — the thing that's killing organic clicks on billions of searches. So this is tracking what Google's AI says about you, which is probably the engine to track first anyway.&lt;/p&gt;

&lt;p&gt;If you need the full 4-engine coverage, buy Otterly or Profound. If you want $19 lifetime and your money back if you don't like the first report, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only Gemini. You know this.&lt;/li&gt;
&lt;li&gt;You need to be comfortable with GitHub Secrets and editing JSON. If &lt;code&gt;git&lt;/code&gt; is a foreign word, this isn't for you yet.&lt;/li&gt;
&lt;li&gt;The "position" number is based on parsing markdown lists in the response. If Gemini answers in prose, you get a mention count instead of a rank. If you're nested under a competitor, that still counts as "mentioned."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Launch deal
&lt;/h2&gt;

&lt;p&gt;I'm pricing it at $19 one-time for the first 20 lifetime buyers (goes up to $29 after). To avoid me funding a Gumroad account cold before I know anyone wants this, the reservation lives as a GitHub issue — drop a one-line comment with your use case and I'll reply with the Gumroad link within 24h.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Landing + sample report: &lt;a href="https://98dkcc488b-glitch.github.io/askvisible-landing/" rel="noopener noreferrer"&gt;https://98dkcc488b-glitch.github.io/askvisible-landing/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source on GitHub: &lt;a href="https://github.com/98dkcc488b-glitch/askvisible" rel="noopener noreferrer"&gt;https://github.com/98dkcc488b-glitch/askvisible&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Reserve a slot: &lt;a href="https://github.com/98dkcc488b-glitch/askvisible/issues/1" rel="noopener noreferrer"&gt;https://github.com/98dkcc488b-glitch/askvisible/issues/1&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions in the comments. If you've tried Profound or Otterly, I'd love to hear what you kept vs. ditched — my prompt-parser is still rough and the next release will handle more answer shapes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>indiehackers</category>
      <category>github</category>
    </item>
    <item>
      <title>Day 6: The blind spot in my own blind-spot detector</title>
      <dc:creator>98dkcc488b-glitch</dc:creator>
      <pubDate>Mon, 19 Oct 2026 14:10:12 +0000</pubDate>
      <link>https://dev.to/98dkcc488bglitch/day-6-the-blind-spot-in-my-own-blind-spot-detector-1j1o</link>
      <guid>https://dev.to/98dkcc488bglitch/day-6-the-blind-spot-in-my-own-blind-spot-detector-1j1o</guid>
      <description>&lt;p&gt;Day 5 landed with one comment. It was the useful kind — the kind that identifies the same trust problem one level up from the one you just solved.&lt;/p&gt;

&lt;p&gt;The comment, roughly: &lt;em&gt;"Nice, you check whether the file was touched. But what if the file was touched for an unrelated reason, and the agent still claims to have fixed something that wasn't fixed?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The suggested fixture: &lt;code&gt;"I fixed the timeout in retry.py"&lt;/code&gt; — where &lt;code&gt;retry.py&lt;/code&gt; exists in the session, but no change related to the timeout was ever made.&lt;/p&gt;

&lt;p&gt;I spent today testing it against my own &lt;code&gt;audit-turn.sh&lt;/code&gt; hook. Here's what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two variants
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Variant A — &lt;code&gt;retry.py&lt;/code&gt; was only read, never written.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claims=1 verified=0 unverified=1 → exit 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blocked. The hook correctly flags the claim as unverified because &lt;code&gt;retry.py&lt;/code&gt; has no write event in &lt;code&gt;.claude/trust-audit.log&lt;/code&gt; and no changes in &lt;code&gt;git status&lt;/code&gt;. This is the case I built the hook for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variant B — &lt;code&gt;retry.py&lt;/code&gt; was written in this session, but with an unrelated edit (say, a comment).&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claims=1 verified=1 unverified=0 → exit 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Waved through. The claim "I fixed the timeout" is marked verified because &lt;code&gt;retry.py&lt;/code&gt; appears in &lt;code&gt;recent_writes ∪ git_touched&lt;/code&gt;. The hook has no idea whether the diff actually touches &lt;code&gt;timeout&lt;/code&gt;-related code. If the agent made any change to the file and claimed any fix in it, the audit passes.&lt;/p&gt;

&lt;p&gt;That's the blind spot. It's real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this isn't a one-line fix
&lt;/h2&gt;

&lt;p&gt;The naive patch is a keyword match: if the claim says "timeout", scan the diff for "timeout". I thought about it for a while and decided against pushing it.&lt;/p&gt;

&lt;p&gt;The failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"fixed the race condition" — no reliable keyword in the diff&lt;/li&gt;
&lt;li&gt;"fixed the off-by-one" — won't appear verbatim&lt;/li&gt;
&lt;li&gt;"fixed the auth bug" — matches any file with "auth" in a variable name&lt;/li&gt;
&lt;li&gt;Multi-word claims where the keyword is a stopword&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keyword heuristics would mostly produce the opposite problem: false negatives on legitimate fixes, which teach the agent to pad its claims with jargon to pass the filter. That's worse than the current false positive.&lt;/p&gt;

&lt;p&gt;The direction I'm thinking about (not shipped, not tested yet) is a protocol change: the agent declares at the end of each turn, in a structured &lt;code&gt;## Changes&lt;/code&gt; block, which file was changed for what reason. The hook then flags mismatch between the declaration and the actual writes.&lt;/p&gt;

&lt;p&gt;That moves the trust problem from "did the file change" to "did the agent declare honestly" — which is a smaller problem, because the declaration lives next to the diff and a human reviewer can spot-check it. But it also requires the agent to cooperate with the protocol, which is a different kind of fragility.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped today instead of a fix
&lt;/h2&gt;

&lt;p&gt;A test file: &lt;a href="https://github.com/98dkcc488b-glitch/did-it-actually/blob/main/tests/known-gaps.sh" rel="noopener noreferrer"&gt;&lt;code&gt;tests/known-gaps.sh&lt;/code&gt;&lt;/a&gt; — reproduces both variants against the current hook, documents the gap, and lives &lt;em&gt;outside&lt;/em&gt; &lt;code&gt;run-tests.sh&lt;/code&gt; so it doesn't fail the suite. It's a honest TODO, not red paint.&lt;/p&gt;

&lt;p&gt;And this post, which is me saying out loud: the hooks I shipped yesterday catch five specific lying patterns reproducibly. They do not catch "I fixed X in a file that got touched for a different reason." If you install them expecting them to, you'll be disappointed.&lt;/p&gt;

&lt;p&gt;I'd rather tell you the gap exists than ship a bad fix that papers over it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/98dkcc488b-glitch/did-it-actually" rel="noopener noreferrer"&gt;github.com/98dkcc488b-glitch/did-it-actually&lt;/a&gt; (MIT) · &lt;strong&gt;PWYW:&lt;/strong&gt; &lt;a href="https://dumkestar6.gumroad.com/l/vicuep" rel="noopener noreferrer"&gt;dumkestar6.gumroad.com/l/vicuep&lt;/a&gt; ($5 min, $9 suggested)&lt;/p&gt;

&lt;p&gt;Previous entries: &lt;a href="https://dev.to/98dkcc488bglitch/how-i-stopped-claude-code-from-lying-about-finishing-tasks-218e"&gt;Day 1 — how I stopped Claude Code from lying&lt;/a&gt; · &lt;a href="https://dev.to/98dkcc488bglitch/a-bug-in-my-own-branch-guard-hook-caught-by-typing-one-thing-wrong-54pb"&gt;Day 4 — a bug in my own branch-guard hook&lt;/a&gt; · &lt;a href="https://dev.to/98dkcc488bglitch/day-5-what-6-strangers-found-in-my-20-line-parser-5b8l"&gt;Day 5 — what 6 strangers found&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Day 5: what 6 strangers found in my 20-line parser</title>
      <dc:creator>98dkcc488b-glitch</dc:creator>
      <pubDate>Sat, 17 Oct 2026 07:16:12 +0000</pubDate>
      <link>https://dev.to/98dkcc488bglitch/day-5-what-6-strangers-found-in-my-20-line-parser-43j7</link>
      <guid>https://dev.to/98dkcc488bglitch/day-5-what-6-strangers-found-in-my-20-line-parser-43j7</guid>
      <description>&lt;h2&gt;
  
  
  Day 5 of "Did it actually?": what 6 strangers found in my 20-line parser
&lt;/h2&gt;

&lt;p&gt;Four days ago I shipped a pack of four Claude Code hooks that catch the agent lying — no-op edits, pushes to the wrong branch, out-of-scope writes, and end-of-turn claims the diff doesn't support. The write-up got a trickle of attention (37 views, 2 reactions, 4 comments on the launch post as of this morning), and a Day-3 follow-up pulled in a few more. What I didn't expect was that the Day-3 article's comment section would turn into the single most valuable code review the project has had.&lt;/p&gt;

&lt;p&gt;Six commenters surfaced issues ranging from "your snippet uses an env var that doesn't exist" to "your branch guard can be defeated by a tab character." Three of those became shipped patches with regression tests. One is a philosophical question I'm still wrestling with. One was a doc fix. One was a real framing I'd undersold.&lt;/p&gt;

&lt;p&gt;This post is the honest version of what they found and what I did about it. If you ship small Bash hooks that pretend to be security guards, you probably want to know what breaks before you trust yours.&lt;/p&gt;




&lt;h3&gt;
  
  
  1. &lt;code&gt;@edtrackai&lt;/code&gt; → silent-failure hole, fixed in v1.1
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Great framing ("narrates work that didn't happen"). One trap with exit-2 guards: if the hook itself crashes, times out or exits 1, Claude Code treats that as a hook error and the call carries on through normal permissions, so a broken guard fails open. Since 2.1.295 you can set &lt;code&gt;"onFailure": "block"&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had &lt;code&gt;set -euo pipefail&lt;/code&gt; in every hook, so I assumed a crashing hook would just block the tool call. That is not what Claude Code does. By default, a hook that exits non-zero (anything other than 0 or 2) is treated as a hook-system error and the tool call proceeds as if no hook were installed. A missing &lt;code&gt;jq&lt;/code&gt;, a corrupt JSON input, a broken &lt;code&gt;python3&lt;/code&gt; symlink — all of these would silently turn my guards into decoration.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;install.sh&lt;/code&gt; now writes &lt;code&gt;"onFailure": "block"&lt;/code&gt; on the three blocking hooks (requires Claude Code ≥ 2.1.295). Verified with the "rename-the-script" trick — rename &lt;code&gt;verify-write.sh&lt;/code&gt; to &lt;code&gt;.off&lt;/code&gt;, trigger an edit, confirm the tool call is refused rather than waved through.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. &lt;code&gt;@skillselion&lt;/code&gt; → two issues, one in the docs and one in the code
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;(a) &lt;code&gt;$CLAUDE_TOOL_INPUT&lt;/code&gt; doesn't exist.&lt;/strong&gt; The snippets in the launch post read a non-existent env var. The &lt;em&gt;shipped&lt;/em&gt; hooks correctly read JSON from stdin, but the readable snippets I'd inlined for the post had been rewritten and I'd invented an env var that isn't part of the hook contract. Edited the live article the same day, added a note crediting the catch. Embarrassing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(b) Audit-turn needs a &lt;code&gt;stop_hook_active&lt;/code&gt; loop guard.&lt;/strong&gt; The Stop event hook can refuse the stop by exiting 2 (which feeds stderr back to Claude as a correction). I'd implemented that. What I'd missed: if Claude re-emits a reply that still contains an unverifiable claim, the Stop hook fires again, refuses again, and you're in an infinite loop that only ends when the agent gives up or crashes.&lt;/p&gt;

&lt;p&gt;Claude Code sends &lt;code&gt;stop_hook_active: true&lt;/code&gt; in the payload when a Stop hook has already refused to stop once this turn. The guard is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stdin_payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stop_hook_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# don't refuse the stop twice.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shipped in v1.3. Regression test: &lt;code&gt;audit-turn honours stop_hook_active loop guard&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. &lt;code&gt;@launchgatecheck&lt;/code&gt; → two detection bugs, one false negative and one false positive
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;I'd add a tab-separated &lt;code&gt;git&amp;lt;TAB&amp;gt;push&lt;/code&gt; fixture to exercise detection as well as parsing. In the simplified case statement, only the one-space and two-space forms reach the parser, so normalization needs to happen before that gate too. Another useful pair is an actual push versus &lt;code&gt;printf '%s' 'git push origin main'&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reproducible, both of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# false negative — TAB sneaks past the detection gate:&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_input":{"command":"git\tpush\torigin\tmain"}}'&lt;/span&gt; | ./block-wrong-branch.sh
&lt;span class="c"&gt;# exit 0 — nothing blocked.&lt;/span&gt;

&lt;span class="c"&gt;# false positive — printf argument is treated as a push command:&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_input":{"command":"printf %%s \"git push origin main\""}}'&lt;/span&gt; | ./block-wrong-branch.sh
&lt;span class="c"&gt;# exit 2 — blocked, but no git is ever executed.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root cause was the same thing: I was treating the Bash command as a flat string to pattern-match, instead of as a shell-tokenised argument list. The TAB wasn't matched by my space-based &lt;code&gt;case&lt;/code&gt; arms, and the quoted &lt;code&gt;printf&lt;/code&gt; argument was matched by the "contains &lt;code&gt;git push&lt;/code&gt;" substring check.&lt;/p&gt;

&lt;p&gt;Fix: swap substring matching for &lt;code&gt;shlex.split&lt;/code&gt;. If the token list starts with &lt;code&gt;git&lt;/code&gt; (optionally after an env-var prefix like &lt;code&gt;FOO=bar git …&lt;/code&gt;) and the next non-flag token is &lt;code&gt;push&lt;/code&gt;, that's a push. Everything else is not. Shipped in v1.4, 4 regression fixtures added (TAB, printf, env prefix, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; chain).&lt;/p&gt;




&lt;h3&gt;
  
  
  4. &lt;code&gt;@reidmarlow&lt;/code&gt; → flag-order bug + git-level flag bypass, both shipped in v1.5
&lt;/h3&gt;

&lt;p&gt;Two issues:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) Flag-order parsing.&lt;/strong&gt; v1.4's &lt;code&gt;shlex&lt;/code&gt; fix handled whitespace correctly, but I was still filtering options by "starts with &lt;code&gt;-&lt;/code&gt;" and treating the next non-dash token as the ref. &lt;code&gt;git push -o signed=yes origin main&lt;/code&gt; has an option whose value lives in a separate token — &lt;code&gt;signed=yes&lt;/code&gt; doesn't start with &lt;code&gt;-&lt;/code&gt;, so my code slurped it as the ref and the real ref &lt;code&gt;main&lt;/code&gt; was never checked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;command            : git push -o signed=yes origin main
v1.4 extracted ref : origin   ← WRONG (treated signed=yes as positional)
v1.5 extracted ref : main     ← CORRECT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;(b) git-level flag bypass.&lt;/strong&gt; The hook pattern-matched for &lt;code&gt;push&lt;/code&gt; as the first non-git token. But &lt;code&gt;git&lt;/code&gt; itself accepts flags before the subcommand — &lt;code&gt;git -C /some/path push origin main&lt;/code&gt; and &lt;code&gt;git -c protocol.version=2 push origin main&lt;/code&gt; have &lt;code&gt;push&lt;/code&gt; as the third token, not the second. v1.4 saw &lt;code&gt;-C&lt;/code&gt; and bailed. Full bypass.&lt;/p&gt;

&lt;p&gt;Fix: an explicit option table for both &lt;code&gt;git&lt;/code&gt; itself (&lt;code&gt;-C PATH&lt;/code&gt;, &lt;code&gt;-c K=V&lt;/code&gt;, &lt;code&gt;-p&lt;/code&gt;, &lt;code&gt;--git-dir=PATH&lt;/code&gt;, …) and &lt;code&gt;git push&lt;/code&gt; (&lt;code&gt;-o VAL&lt;/code&gt;, &lt;code&gt;--push-option VAL&lt;/code&gt;, &lt;code&gt;--repo=URL&lt;/code&gt;, &lt;code&gt;-u&lt;/code&gt;, …), consuming each option plus its value where appropriate, and only &lt;em&gt;then&lt;/em&gt; reading &lt;code&gt;remote&lt;/code&gt; and &lt;code&gt;refspec&lt;/code&gt;. Shipped v1.5 with 6 regression fixtures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(c) stderr poisoning.&lt;/strong&gt; Reid also raised a broader design point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When a pre-tool hook rejects a command with misleading feedback, Claude Code usually starts editing surrounding flags or inventing nonexistent subcommands to satisfy the error text instead of fixing the underlying argument.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He's right. v1.4's error message said "Blocked: &lt;code&gt;git push&lt;/code&gt; to branch 'origin' is not allowed by .claude/branch-policy.json" when the actual command was pushing to &lt;code&gt;main&lt;/code&gt; — the parser was wrong but the stderr was confident. A model being told "origin is not allowed" will try &lt;code&gt;git push -u origin main&lt;/code&gt;, &lt;code&gt;git push --repo=origin main&lt;/code&gt;, invent &lt;code&gt;git push --target=…&lt;/code&gt; — none of which fix the real problem.&lt;/p&gt;

&lt;p&gt;I'd argue the fix for stderr poisoning is "don't poison the stderr" — i.e. make sure the parsed branch in the error message is the branch the hook actually read. v1.5's output now says the ref it extracted &lt;em&gt;and&lt;/em&gt; the full tokenised command for context. Not a complete answer, but it closes the specific trap where the error message points the model at a non-existent problem.&lt;/p&gt;




&lt;h3&gt;
  
  
  5. &lt;code&gt;@paul_irolla&lt;/code&gt; → the one I don't have a shipped fix for
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;A check that reads the agent's own transcript to decide whether the work happened will pass whatever the agent wrote. Test the claim against what the tools changed instead: the diff, the branch, the exit code of the test run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The nuance here matters for anyone building this kind of hook, so let me be precise about what my hook actually does and where Paul's critique lands.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;audit-turn.sh&lt;/code&gt; does two separate things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extract claims from the transcript.&lt;/strong&gt; It reads the last assistant message, splits into sentences, looks for first-person perfective verbs ("added", "created", "wrote", "fixed", "pushed", …) co-occurring with a filename-looking token. That's the &lt;em&gt;claim set&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare to evidence.&lt;/strong&gt; The evidence set is &lt;code&gt;recent_writes&lt;/code&gt; (file paths recorded by &lt;code&gt;verify-write.sh&lt;/code&gt; from the &lt;code&gt;tool_input&lt;/code&gt; field of actual Edit/Write/MultiEdit calls, in the last hour) ∪ &lt;code&gt;git_touched&lt;/code&gt; (output of &lt;code&gt;git status --porcelain&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the hook already does what Paul is suggesting for the &lt;em&gt;evidence&lt;/em&gt; half — it compares against tool-event records and git state, not against anything the model self-reported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But&lt;/strong&gt; Paul's deeper point stands: the &lt;em&gt;claim side&lt;/em&gt; is still regex over the model's own text. If the model writes "updated config.py" but means it metaphorically ("I've been thinking about updating config.py"), or omits a filename, or claims a change by paraphrasing ("the auth module now accepts tokens"), my regex won't catch it. And the regex is also where false positives come from — "I removed the TODO" matches &lt;code&gt;removed&lt;/code&gt; but refers to a comment deletion, which is a real edit the audit log will have.&lt;/p&gt;

&lt;p&gt;I don't have a cleanly shipped answer here. The two routes I see are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expand the claim side.&lt;/strong&gt; Add more verb forms, add a "the X now does Y" pattern that doesn't require a filename at all, back off to flagging &lt;em&gt;any&lt;/em&gt; assistant message that doesn't explicitly cite a file in a session that had writes. Easy to implement, more false positives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flip the model.&lt;/strong&gt; Instead of extracting claims and checking them, require every assistant end-of-turn to produce an explicit &lt;code&gt;## Changes&lt;/code&gt; block that enumerates files touched. Fail the Stop if the block is missing or doesn't match the write log. Cleaner but changes the UX substantially and relies on the agent cooperating with the format.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm genuinely interested in which direction readers would push on. The hook has been live for 4 days and the regex-based claim extraction hasn't produced a false positive yet in my own usage — but the sample size is tiny and the design is fragile.&lt;/p&gt;




&lt;h3&gt;
  
  
  6. &lt;code&gt;@brethofai&lt;/code&gt; → tip, not a bug
&lt;/h3&gt;

&lt;p&gt;The suggestion to compare the end-of-turn summary against the actual diff &lt;em&gt;inside&lt;/em&gt; the turn (rather than at Stop) is a nicer place to catch no-op edits than my Stop-based audit. I couldn't find a hook point that fires between "model stops calling tools" and "model emits final text", which is where you'd need to be to block mid-turn. If you've found one, I'd like to compare notes.&lt;/p&gt;




&lt;h3&gt;
  
  
  What this cost and what it bought
&lt;/h3&gt;

&lt;p&gt;The whole v1.3 → v1.5 run was 48 hours of calendar time, maybe 6 hours of actual coding plus writing. Three fixes, two of them with full regression fixtures, one changelog backfill, and a public thread of credit for each person who found something. The code went from 26 to 37 passing tests.&lt;/p&gt;

&lt;p&gt;What it bought me is harder to measure. I now trust the hooks more than I did on launch day, because they've been attacked and some of the attacks worked. I have a running list of the &lt;em&gt;shape&lt;/em&gt; of bugs a shell-string-matching parser is prone to, which is useful the next time I write one. And the Day-3 post has 29 views right now — not a lot, but every one of those commenters was a developer who read the code closely enough to find a bug, which is worth more than 1,000 drive-by views.&lt;/p&gt;

&lt;p&gt;If you ship small guards like this, I'd suggest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default to &lt;code&gt;onFailure: "block"&lt;/code&gt; on every PreToolUse hook that pretends to protect something. The fail-open default is the single worst thing in the hook system right now.&lt;/li&gt;
&lt;li&gt;Parse shell commands with &lt;code&gt;shlex&lt;/code&gt;, not substrings. The number of ways to express "git push" is depressingly large.&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;stop_hook_active&lt;/code&gt; on your Stop hooks. The loop they create otherwise is the kind of bug you only find when you ship.&lt;/li&gt;
&lt;li&gt;Make your error messages name the thing you actually parsed, not the thing you think you parsed. If the model is going to react to your stderr, it had better match reality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo is MIT-licensed, hooks are 150 lines of Bash and Python, and the full test suite is in &lt;code&gt;tests/run-tests.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo (MIT, free):&lt;/strong&gt; &lt;a href="https://github.com/98dkcc488b-glitch/did-it-actually" rel="noopener noreferrer"&gt;https://github.com/98dkcc488b-glitch/did-it-actually&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Packaged version with &lt;code&gt;install.sh&lt;/code&gt; and the 37 tests:&lt;/strong&gt; &lt;a href="https://dumkestar6.gumroad.com/l/vicuep" rel="noopener noreferrer"&gt;https://dumkestar6.gumroad.com/l/vicuep&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Launch post with the full hook walkthrough:&lt;/strong&gt; &lt;a href="https://dev.to/98dkcc488bglitch/how-i-stopped-claude-code-from-lying-about-finishing-tasks-4-hooks-150-lines-of-bash-218e"&gt;Day 1&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;The bug &lt;a class="mentioned-user" href="https://dev.to/launchgatecheck"&gt;@launchgatecheck&lt;/a&gt; caught:&lt;/strong&gt; &lt;a href="https://dev.to/98dkcc488bglitch/a-bug-in-my-own-branch-guard-hook-caught-by-typing-one-extra-space-5b8l"&gt;Day 3 write-up&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks to everyone who read the code closely enough to find something broken.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>A bug in my own branch-guard hook, caught by typing one extra space</title>
      <dc:creator>98dkcc488b-glitch</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:03:34 +0000</pubDate>
      <link>https://dev.to/98dkcc488bglitch/a-bug-in-my-own-branch-guard-hook-caught-by-typing-one-extra-space-5b8l</link>
      <guid>https://dev.to/98dkcc488bglitch/a-bug-in-my-own-branch-guard-hook-caught-by-typing-one-extra-space-5b8l</guid>
      <description>&lt;h1&gt;
  
  
  A bug in my own branch-guard hook, caught by typing one extra space
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; Last week I shipped a set of 4 Claude Code hooks that catch common agent mistakes — no-op edits, pushes to the wrong branch, out-of-scope writes, and unverified claims at the end of a turn. I wrote about them &lt;a href="https://dev.to/98dkcc488bglitch/how-i-stopped-claude-code-from-lying-about-finishing-tasks-4-hooks-150-lines-of-bash-218e"&gt;in article #1&lt;/a&gt; and the testing in &lt;a href="https://dev.to/98dkcc488bglitch/26-test-cases-for-a-20-line-bash-function-and-what-each-one-taught-me-about-claude-code-gbc"&gt;#2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This piece is the small, embarrassing follow-up: a bug I found by dog-fooding the hooks on their own payloads.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hook
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;block-wrong-branch.sh&lt;/code&gt; runs on &lt;code&gt;PreToolUse(Bash)&lt;/code&gt; for &lt;code&gt;git push&lt;/code&gt;. It extracts the target branch and matches it against &lt;code&gt;.claude/branch-policy.json&lt;/code&gt;. Simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Only inspect git push commands.&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"git push"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"git  push"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; : &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;

&lt;span class="c"&gt;# Tokens after "git push".&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;command&lt;/span&gt;&lt;span class="p"&gt;#*git push&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; tokens &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tail&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ...pick the branch out of the positional args...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detection step accepts both &lt;code&gt;git push&lt;/code&gt; and &lt;code&gt;git  push&lt;/code&gt; (I knew doubled whitespace shows up in real commands — people copy-paste). The parser step uses &lt;code&gt;${command#*git push}&lt;/code&gt; to strip everything up to and including &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;See the bug yet? I didn't, for a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I typed
&lt;/h2&gt;

&lt;p&gt;I was writing new test cases and typed one with a doubled space by accident:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"tool_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tool_input"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"git  push origin main"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fed it to the hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;2
stderr: Blocked: &lt;span class="sb"&gt;`&lt;/span&gt;git push&lt;span class="sb"&gt;`&lt;/span&gt; to branch &lt;span class="s2"&gt;"push"&lt;/span&gt; is not allowed by .claude/branch-policy.json.
        Reason: not &lt;span class="k"&gt;in &lt;/span&gt;allow-list &lt;span class="o"&gt;[&lt;/span&gt;...].
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The block was correct — nothing matched the allow list, fail-safe wins. But the branch name it reported was &lt;code&gt;push&lt;/code&gt;. Not &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;${command#*git push}&lt;/code&gt; is pattern-matching for a literal &lt;code&gt;git push&lt;/code&gt; with one space. The input had two. Shell parameter expansion doesn't collapse whitespace runs, so the prefix pattern never matched. &lt;code&gt;tail&lt;/code&gt; was unchanged — still the full command &lt;code&gt;git  push origin main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;read -r -a tokens &amp;lt;&amp;lt;&amp;lt; "$tail"&lt;/code&gt; tokenizes it. Bash word-splitting &lt;em&gt;does&lt;/em&gt; collapse whitespace, so tokens became &lt;code&gt;[git, push, origin, main]&lt;/code&gt;. The parser picks the second positional argument as the branch → &lt;code&gt;push&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Case-statement glob = whitespace-sensitive. Parameter expansion = whitespace-sensitive. Word-splitting = whitespace-insensitive. Three different rules in twenty lines of bash. The detection step forgave the double space; the parser didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line, at the top of the command handling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Normalize runs of whitespace so `git  push` parses the same as `git push`.&lt;/span&gt;
&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$command&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;' \t'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Added a regression test (&lt;code&gt;"git  push origin main"&lt;/code&gt; must report blocked branch &lt;code&gt;main&lt;/code&gt;, not &lt;code&gt;push&lt;/code&gt;). 26 → 27 tests. Shipped as v1.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm writing this up
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One:&lt;/strong&gt; the hook block was &lt;em&gt;correct&lt;/em&gt; — the kit didn't let a bad push through. But the error message lied about &lt;em&gt;why&lt;/em&gt;. That's exactly the behavior I built this tool to prevent in agents: not catastrophic wrong actions, just small, confident, wrong-shaped claims about what happened. "Blocked branch push" is a factually wrong feedback string the LLM would then try to reason about ("hm, I didn't try to push a branch called &lt;code&gt;push&lt;/code&gt;, did I?"). Dog-fooding caught it before any user did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two:&lt;/strong&gt; when you build hook scripts for an LLM, the LLM is going to read your stderr and reason about it. Vague or wrong error strings don't just hurt the user — they poison the agent's next turn. "Blocked: branch &lt;code&gt;push&lt;/code&gt; not allowed" is in some ways worse than no feedback, because now the agent is going to try to work around a constraint that doesn't exist.&lt;/p&gt;

&lt;p&gt;Adversarial inputs worth testing on your own hook parsers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Doubled or trailing whitespace in commands&lt;/li&gt;
&lt;li&gt;Branch names with &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt;, or &lt;code&gt;-force&lt;/code&gt; as a literal substring (not as a flag)&lt;/li&gt;
&lt;li&gt;Refspecs with &lt;code&gt;src:dst&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Empty stdin / malformed JSON&lt;/li&gt;
&lt;li&gt;Correct JSON with a missing &lt;code&gt;tool_input&lt;/code&gt; key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My 27-test harness runs these in under 20ms. If your hooks don't have a harness, they'll have the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hooks, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;verify-write.sh&lt;/strong&gt; — on &lt;code&gt;PostToolUse(Edit|Write|MultiEdit)&lt;/code&gt;, diffs the file against the index. Zero bytes changed → exit 2 with a correction prompt. Catches no-op edits where the LLM later claims "I updated X."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;block-wrong-branch.sh&lt;/strong&gt; — on &lt;code&gt;PreToolUse(Bash)&lt;/code&gt; for &lt;code&gt;git push&lt;/code&gt;, parses the branch out of the command and matches it against &lt;code&gt;.claude/branch-policy.json&lt;/code&gt;. Fail-safe: unknown branch, block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;scope-fence.sh&lt;/strong&gt; — on &lt;code&gt;PreToolUse(Edit|Write)&lt;/code&gt;, matches the target path against allow/deny globs in &lt;code&gt;.claude/scope.json&lt;/code&gt;. Fail-safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;audit-turn.sh&lt;/strong&gt; — on &lt;code&gt;Stop&lt;/code&gt;, scans the final assistant message for "I updated/created/added X" style claims and cross-references &lt;code&gt;trust-audit.log&lt;/code&gt;. If the file isn't in the log, block with "either make the change or correct your previous message." Has a &lt;code&gt;stop_hook_active&lt;/code&gt; loop-guard — a reader of article #2 pointed out the missing one, that was v1.2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The kit
&lt;/h2&gt;

&lt;p&gt;Hooks + install script + 27-test harness + two policy JSON files. MIT. On &lt;a href="https://dumkestar6.gumroad.com/l/vicuep" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt; with a launch coupon (&lt;code&gt;SHOWDEV50&lt;/code&gt; → half off).&lt;/p&gt;

&lt;p&gt;If you build hooks and your parser trips on something weird, reply with the payload — I'll run it against mine. That's the kind of feedback that made v1.1, v1.2, and v1.3 each better than the last.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>showdev</category>
      <category>devtools</category>
    </item>
    <item>
      <title>26 test cases for a 20-line bash function (and what each one taught me about Claude Code)</title>
      <dc:creator>98dkcc488b-glitch</dc:creator>
      <pubDate>Thu, 15 Oct 2026 12:30:48 +0000</pubDate>
      <link>https://dev.to/98dkcc488bglitch/26-test-cases-for-a-20-line-bash-function-and-what-each-one-taught-me-about-claude-code-gbc</link>
      <guid>https://dev.to/98dkcc488bglitch/26-test-cases-for-a-20-line-bash-function-and-what-each-one-taught-me-about-claude-code-gbc</guid>
      <description>&lt;p&gt;I shipped a Claude Code hook pack last week called &lt;a href="https://dumkestar6.gumroad.com/l/vicuep" rel="noopener noreferrer"&gt;Did-It-Actually&lt;/a&gt;. The headline feature is one hook: a &lt;code&gt;PostToolUse&lt;/code&gt; script that catches Claude when it claims an edit happened but nothing actually changed on disk.&lt;/p&gt;

&lt;p&gt;The hook itself is twenty lines of bash. Writing it was an afternoon. &lt;strong&gt;Testing it was two days.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That gap surprised me enough that I want to walk through it, because I think it says something about the hook-authoring job that isn't obvious from the docs. If you're writing hooks of your own, the lesson is: the hook is the easy part. Deciding what counts as "a real change" is where the actual thinking lives.&lt;/p&gt;

&lt;p&gt;Here are the 26 test cases I ended up with, grouped by the question each one answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 1: the "did anything change at all" cases (6 tests)
&lt;/h2&gt;

&lt;p&gt;These are the ones you expect to write. They establish the baseline: given a &lt;code&gt;PostToolUse&lt;/code&gt; event on &lt;code&gt;Edit&lt;/code&gt;, did git see a diff?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test-01-basic-edit-produces-diff-passes
test-02-identical-content-write-fails
test-03-empty-file-to-empty-file-fails
test-04-create-new-file-passes
test-05-delete-via-write-fails  # Write cannot delete; this is the empty-content edge
test-06-same-content-different-line-endings-fails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing surprising here. The one worth calling out is &lt;strong&gt;test 06&lt;/strong&gt;: Claude Code occasionally normalizes line endings on write, so a file with CRLF that gets rewritten as LF is a diff — but it's a diff that nobody intended. In practice this fires on Windows users under WSL, so I handle it with a &lt;code&gt;git diff --ignore-cr-at-eol&lt;/code&gt; fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 2: the whitespace cases (5 tests)
&lt;/h2&gt;

&lt;p&gt;This is where the fun starts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test-07-trailing-newline-only-fails
test-08-leading-whitespace-only-passes
test-09-trailing-whitespace-only-passes
test-10-indentation-change-only-passes
test-11-blank-line-insertion-passes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The judgment call: &lt;strong&gt;a trailing-newline-only diff is almost always a POSIX-compliance edit, not meaningful work.&lt;/strong&gt; Nearly every modern editor adds one. Claude's model has internalized this and often adds a newline on write without being asked. If I count that as "the edit changed something", the hook never fires on a real no-op, because every save produces at least that one byte of diff.&lt;/p&gt;

&lt;p&gt;So I strip the final-newline-only case explicitly, and treat it as a no-op.&lt;/p&gt;

&lt;p&gt;But the other four — leading/trailing whitespace on content lines, indentation, blank-line insertion — I treat as real diffs. If Claude &lt;em&gt;intended&lt;/em&gt; to fix indentation, that's work. The hook shouldn't second-guess intent; it should check whether work happened.&lt;/p&gt;

&lt;p&gt;I expose this as a config flag, because teams that use &lt;code&gt;pre-commit&lt;/code&gt; for whitespace will want to tighten it. The default is "trailing-newline-only is a no-op, everything else is real."&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 3: the Claude-specific cases (6 tests)
&lt;/h2&gt;

&lt;p&gt;These are the ones I only knew to write &lt;em&gt;after&lt;/em&gt; I'd been bitten.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test-12-old-string-identical-to-new-string-fails
test-13-old-string-already-not-in-file-fails-silently
test-14-write-of-identical-content-after-read-fails
test-15-multiedit-with-one-noop-chunk-passes
test-16-multiedit-with-all-noop-chunks-fails
test-17-edit-of-a-generated-dotfile-fails-with-warning
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test 12 is the classic. Claude's &lt;code&gt;Edit&lt;/code&gt; tool takes &lt;code&gt;old_string&lt;/code&gt; and &lt;code&gt;new_string&lt;/code&gt;; if they're identical (which the model occasionally produces when it "fixes" something that was already correct), the file is rewritten byte-for-byte and git sees no change. Without this test, the hook silently passes.&lt;/p&gt;

&lt;p&gt;Test 13 is the one I lost half a day to. The &lt;code&gt;Edit&lt;/code&gt; tool is supposed to error when &lt;code&gt;old_string&lt;/code&gt; is not found. In some paths (particularly when the file was recently rewritten by a sibling tool call), the error is swallowed, the edit is reported as applied, and the file is untouched. The hook needs to catch &lt;em&gt;the file being unchanged&lt;/em&gt;, independent of whether the tool reported success — because the tool report lies.&lt;/p&gt;

&lt;p&gt;Test 14 is the one that comes up most in logs. Claude does a &lt;code&gt;Read&lt;/code&gt;, then decides to "ensure" something is in the file, then does a &lt;code&gt;Write&lt;/code&gt; with the exact bytes it just read. The write is successful; the diff is empty.&lt;/p&gt;

&lt;p&gt;Tests 15 and 16 are the &lt;code&gt;MultiEdit&lt;/code&gt; versions. &lt;code&gt;MultiEdit&lt;/code&gt; applies a sequence of edits in one call. If &lt;em&gt;any&lt;/em&gt; chunk produces a diff, I pass the hook (the model did real work, even if not all of it). Only an all-no-op &lt;code&gt;MultiEdit&lt;/code&gt; fails.&lt;/p&gt;

&lt;p&gt;Test 17 is a late addition. Some projects have generated dotfiles (&lt;code&gt;.pre-commit-config.yaml&lt;/code&gt;, &lt;code&gt;.github/workflows/*&lt;/code&gt;) that Claude will touch and then re-generate. The regen is a no-op, but the file is tracked. I log it but don't exit 2, because exit 2 would create a loop (Claude re-edits in response to the correction, which also produces a no-op, which also fires the hook).&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 4: the git-state cases (5 tests)
&lt;/h2&gt;

&lt;p&gt;These are the ones that have nothing to do with Claude and everything to do with git being quirky.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test-18-edit-of-untracked-file-passes-if-content-differs
test-19-edit-of-ignored-file-passes-via-fallback-diff
test-20-edit-during-merge-conflict-skips-hook
test-21-edit-of-file-in-detached-head-passes
test-22-edit-when-repo-has-no-commits-passes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git diff HEAD&lt;/code&gt; behaves surprisingly in several of these. For untracked files, &lt;code&gt;git diff HEAD -- path&lt;/code&gt; returns empty. For &lt;code&gt;.gitignore&lt;/code&gt;'d files, same. For a brand-new repo with no commits, there's no &lt;code&gt;HEAD&lt;/code&gt; to diff against.&lt;/p&gt;

&lt;p&gt;The fallback is a direct &lt;code&gt;cmp&lt;/code&gt; against the file's state as it was captured by a sibling &lt;code&gt;PreToolUse&lt;/code&gt; hook. The pre-hook stashes the pre-edit bytes in a tempfile keyed on the file path and the tool call ID. The post-hook does &lt;code&gt;cmp -s&lt;/code&gt; of the current file against that tempfile. If they differ, there was a change; otherwise it's a no-op.&lt;/p&gt;

&lt;p&gt;The merge conflict case is special: during a merge, the file is in an unstable state. The hook skips entirely and logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 5: the integration cases (4 tests)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test-23-hook-under-concurrent-edits
test-24-hook-with-large-file-10mb
test-25-hook-with-binary-file
test-26-hook-with-symlink
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The concurrency test runs two &lt;code&gt;Edit&lt;/code&gt; calls back-to-back on the same file; the hook has to key its state by tool-call ID or the second edit will see the first edit's pre-state and report a no-op.&lt;/p&gt;

&lt;p&gt;The 10MB test is pure performance — I need the hook to complete in under 100ms so it doesn't make every &lt;code&gt;Edit&lt;/code&gt; feel laggy. It currently runs in about 40ms on a 10MB text file; &lt;code&gt;cmp&lt;/code&gt; is fast.&lt;/p&gt;

&lt;p&gt;The binary case was unexpected: Claude occasionally edits binary files (compiled fixtures, PNGs). I bail out early if &lt;code&gt;file --mime-type&lt;/code&gt; returns anything non-text, because comparing bytes is wasteful and false-positives aren't useful for binary.&lt;/p&gt;

&lt;p&gt;The symlink case was the last to be written. I resolve symlinks in both the pre- and post-state, because otherwise an edit to a symlink target reads as a no-op on the symlink file itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cost me
&lt;/h2&gt;

&lt;p&gt;Writing the test cases took about twice as long as writing the hook. Reading them back now, I'd say:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Six&lt;/strong&gt; of the 26 are tests I would have written without being bitten (the Category 1 baselines).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twelve&lt;/strong&gt; are tests I added after specific failures in real sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eight&lt;/strong&gt; are tests I wrote because I was going through the git/filesystem edge-case list systematically, trying to find corners the hook didn't handle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 12 "added after failure" tests are the ones that matter most, and the reason the hook is worth running at all. If I'd shipped it with only Category 1, it would have caught maybe a third of the no-op cases I see in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're writing hooks
&lt;/h2&gt;

&lt;p&gt;Three things I'd pass on, now that this is behind me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A hook is a classifier, not a filter.&lt;/strong&gt; You're building something that answers "is this call a real edit or a no-op?" — the hook infrastructure around it is maybe 10% of the work. The other 90% is the classifier, and the classifier is where every edge case lives.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write the test for every failure you see in a real session.&lt;/strong&gt; Not "the kind of failure" — the specific sequence. Keep the Edit JSON, keep the file-before, keep the file-after. Replay them. These replays catch regressions when you change the normalization logic later.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Exit 2 is a feedback channel, not a punishment.&lt;/strong&gt; Your &lt;code&gt;stderr&lt;/code&gt; goes back to Claude as a correction prompt. Think of it as writing an error message for a specific audience that will try to work around it on the next turn. "The edit of foo.py produced no diff" is informative; "if the file already contained what you intended, say so explicitly" is actionable. The second gets the agent to produce a different behavior on the retry.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full test suite + the hook source is in the public &lt;a href="https://github.com/98dkcc488b-glitch/did-it-actually" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;. The packaged install (with the other three hooks and an audit CLI) is &lt;a href="https://dumkestar6.gumroad.com/l/vicuep/SHOWDEV50" rel="noopener noreferrer"&gt;Did-It-Actually on Gumroad&lt;/a&gt; — $9.50 with the &lt;code&gt;SHOWDEV50&lt;/code&gt; code through Oct 21.&lt;/p&gt;

&lt;p&gt;Happy to answer specific "how do you handle X" questions in the comments — the no-op detector has edge cases I probably still haven't seen.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was drafted with AI assistance and reviewed for accuracy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>showdev</category>
      <category>devtools</category>
    </item>
    <item>
      <title>How I stopped Claude Code from lying about finishing tasks (4 hooks, 150 lines of bash)</title>
      <dc:creator>98dkcc488b-glitch</dc:creator>
      <pubDate>Wed, 14 Oct 2026 19:19:25 +0000</pubDate>
      <link>https://dev.to/98dkcc488bglitch/how-i-stopped-claude-code-from-lying-about-finishing-tasks-4-hooks-150-lines-of-bash-218e</link>
      <guid>https://dev.to/98dkcc488bglitch/how-i-stopped-claude-code-from-lying-about-finishing-tasks-4-hooks-150-lines-of-bash-218e</guid>
      <description>&lt;p&gt;Last month Claude Code told me it had added a regression test. The commit landed. The PR merged. Two days later a bug slipped through — the test wasn't actually there. The agent had claimed the work, generated a convincing diff summary at the end of the turn, and the tool-call log looked clean. But &lt;code&gt;git log -p&lt;/code&gt; showed nothing new in the test file.&lt;/p&gt;

&lt;p&gt;That is a specific failure mode worth naming. The model doesn't hallucinate in the "makes up an API" sense. It hallucinates in the "narrates work that didn't happen" sense. Edits silently drop. &lt;code&gt;write_file&lt;/code&gt; collapses to a no-op because the content it generated already matched the file. Branch names drift. And the summary at the end of the turn confidently reports what &lt;em&gt;should&lt;/em&gt; have happened, not what actually did.&lt;/p&gt;

&lt;p&gt;I built a small trust-but-verify layer that catches these at the hook boundary instead of in production. Four hooks, pure bash, zero runtime dependencies. Here's how each one works, and the one non-obvious thing I learned while writing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one Claude Code hook contract fact everything hangs on
&lt;/h2&gt;

&lt;p&gt;Claude Code hooks have an &lt;a href="https://docs.claude.com/claude-code/hooks" rel="noopener noreferrer"&gt;exit-code contract&lt;/a&gt; you can use to intervene before and after tool calls. The part worth memorizing is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit 0&lt;/strong&gt; — allow the tool call, swallow stderr.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit 2&lt;/strong&gt; — block the tool call, and feed stderr back to the model as a correction prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exit 2 is the whole trick. Your hook doesn't just log a complaint — it tells the model, in-band, why its action was rejected and what to do instead. The model sees your stderr the way it would see any other tool result, and corrects. The agent doesn't need to know a hook exists; it just experiences a world where some actions get a clear "that didn't work, here's why" response.&lt;/p&gt;

&lt;p&gt;Once you internalize that, you stop writing "warnings" and start writing hooks that &lt;strong&gt;shape the agent's next move&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on the snippets.&lt;/strong&gt; Claude Code hooks receive their input as JSON on stdin (not as &lt;code&gt;$CLAUDE_TOOL_INPUT&lt;/code&gt; — thanks to &lt;a href="https://dev.to/skillselion"&gt;@skillselion&lt;/a&gt; in the comments for catching an earlier draft that got this wrong). The snippets below show the real stdin-reading pattern; the full source in the repo uses &lt;code&gt;python3 -c&lt;/code&gt; for the trickier parsing but the shape is the same.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Hook 1: verify-write
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;PostToolUse&lt;/code&gt; hook on &lt;code&gt;Edit&lt;/code&gt; and &lt;code&gt;Write&lt;/code&gt;. It checks whether the tool call produced any actual change in the working tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# simplified; the real hook reads the Claude Code hook payload on stdin.&lt;/span&gt;
&lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.file_path // .tool_input.path // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;staged_before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pre_tool_diff_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# written by a sibling PreToolUse&lt;/span&gt;
&lt;span class="nv"&gt;staged_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff HEAD &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$staged_before&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$staged_after&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"verify-write: the edit of &lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt; produced no change."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"If the file already contained what you intended, say so explicitly instead of re-editing."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three failure modes it catches in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The no-op edit.&lt;/strong&gt; The model's &lt;code&gt;old_string&lt;/code&gt; and &lt;code&gt;new_string&lt;/code&gt; are identical modulo whitespace, so the file writes identical bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The convinced-already edit.&lt;/strong&gt; The content the model "wanted" to add is already in the file (common after a prior rewrite), so the apply succeeds but changes nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wrong-file edit.&lt;/strong&gt; The model edits a backup or a &lt;code&gt;.bak&lt;/code&gt; and still reports "updated X".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The non-obvious part: you can't just compare bytes before and after the tool call. Normalization matters. A trailing-newline-only diff is almost always semantic noise and should not count. A whitespace-only diff is a judgment call — I treat it as a real diff by default, because if the model &lt;em&gt;intended&lt;/em&gt; a whitespace fix, that's work. But I expose a config flag so a team can tighten it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook 2: block-wrong-branch
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;PreToolUse&lt;/code&gt; hook on &lt;code&gt;Bash&lt;/code&gt; that matches &lt;code&gt;git push&lt;/code&gt; and checks whether the current branch is in an allowlist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;~ &lt;span class="s2"&gt;"git push"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse &lt;span class="nt"&gt;--abbrev-ref&lt;/span&gt; HEAD&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.allowed_push_branches[]'&lt;/span&gt; .claude/config.json 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qx&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$allowed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"block-wrong-branch: refusing to push &lt;/span&gt;&lt;span class="nv"&gt;$branch&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"This repo only pushes from: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$allowed&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;2
  &lt;span class="k"&gt;fi
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote this after Claude Code pushed what it believed was a feature branch, which was actually &lt;code&gt;main&lt;/code&gt;, because it had checked out a different branch halfway through the session and lost track. The hook refuses the push and tells the model what the allowlist is — so the next thing it does is check out the right branch, not re-attempt the same push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook 3: scope-fence
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;SessionStart&lt;/code&gt; hook that writes a declared-scope file (&lt;code&gt;.claude/.current-scope&lt;/code&gt;) and a &lt;code&gt;PreToolUse&lt;/code&gt; hook on &lt;code&gt;Edit&lt;/code&gt;/&lt;code&gt;Write&lt;/code&gt; that rejects any edit to a path outside the fence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PreToolUse — hooks receive the full payload on stdin, so pull tool_input from there.&lt;/span&gt;
&lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.file_path // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;fence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; .claude/.current-scope 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fence&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;~ ^&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fence&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"scope-fence: &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt; is outside the declared scope."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Current scope: &lt;/span&gt;&lt;span class="nv"&gt;$fence&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"If this edit is intentional, update the scope explicitly."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fence is a regex set during session start — "today I'm touching &lt;code&gt;src/auth/.*&lt;/code&gt; and &lt;code&gt;tests/auth/.*&lt;/code&gt;". When Claude Code decides mid-session that it also needs to refactor the ORM layer, the hook catches it. The point isn't that cross-cutting edits are always wrong. The point is that they should be &lt;em&gt;acknowledged&lt;/em&gt;. If the agent has to re-declare the scope out loud, I at least get to see it coming instead of discovering it in the diff three turns later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hook 4: audit-turn
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Stop&lt;/code&gt; hook (runs at the end of a turn, after the final assistant message). It parses the last message for first-person claims — "I added …", "I fixed …", "the tests now pass" — and checks each one against reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"I added &lt;code&gt;X&lt;/code&gt;" → does &lt;code&gt;git diff&lt;/code&gt; show additions in &lt;code&gt;X&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;"The tests now pass" → was there a successful test run in the tool-call log for this turn?&lt;/li&gt;
&lt;li&gt;"I pushed to &lt;code&gt;branch&lt;/code&gt;" → does &lt;code&gt;git log origin/branch..HEAD&lt;/code&gt; show the commit?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mismatches get logged to &lt;code&gt;.claude/trust-audit.log&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; exit 2 — which, for a &lt;code&gt;Stop&lt;/code&gt; hook, refuses the stop and feeds stderr back to Claude, so the agent gets one more turn to actually make the change or correct the claim. (The Stop payload carries &lt;code&gt;stop_hook_active=true&lt;/code&gt; the second time around; the hook honours it and lets the stop go through, so an unverifiable claim can't loop forever.) The log also feeds a &lt;code&gt;trust-report&lt;/code&gt; CLI that prints a rolling claim-match ratio per repo per week.&lt;/p&gt;

&lt;p&gt;I started at 71% in the first week. After two weeks of the first three hooks nudging the agent toward actually doing what it said, the ratio climbed to 94%. Not because the model got better — it's the same model — but because the hooks remove the low-friction path to "lying by narration".&lt;/p&gt;

&lt;h2&gt;
  
  
  The one surprising lesson
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Writing the no-op detector was the hardest part&lt;/strong&gt;, and I didn't expect that. Deciding what counts as a "real change" has three or four edge cases that each deserve their own test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whitespace-only edits&lt;/li&gt;
&lt;li&gt;trailing-newline-only edits&lt;/li&gt;
&lt;li&gt;semantically equivalent reformats (e.g. the model moves an import, nothing else)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;write_file&lt;/code&gt; that recreates an identical file after a &lt;code&gt;read_file&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ended up shipping 26 integration tests just for the four hooks and the install script, because I couldn't trust the hooks until I'd seen them pass and fail on known inputs. If you build your own, test them. The whole point is to be the thing you trust when you've stopped trusting the model's self-report; your hooks have to earn that trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;If you want to try it on an existing repo, I've packaged the four hooks plus the &lt;code&gt;trust-report&lt;/code&gt; CLI as &lt;a href="https://dumkestar6.gumroad.com/l/vicuep/SHOWDEV50" rel="noopener noreferrer"&gt;Did-It-Actually&lt;/a&gt;. The install script safely merges into &lt;code&gt;.claude/settings.json&lt;/code&gt; with a backup, so you can reverse it. MIT license, works with Claude Code ≥ 1.0.60.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Launch discount for #showdev readers:&lt;/strong&gt; &lt;code&gt;SHOWDEV50&lt;/code&gt; cuts it to &lt;strong&gt;$9.50&lt;/strong&gt; — first 25 copies, through Oct 21. The link above applies the code automatically.&lt;/p&gt;

&lt;p&gt;If you'd rather just read the no-op detector and crib the idea, the public &lt;a href="https://github.com/98dkcc488b-glitch/did-it-actually" rel="noopener noreferrer"&gt;repo on GitHub&lt;/a&gt; has the full source.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments — how I handle false positives on whitespace, how the audit-turn parser handles conditional claims ("I think this should pass"), or how to adapt the scope fence for monorepos where "scope" is multiple packages at once.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>showdev</category>
      <category>devtools</category>
    </item>
  </channel>
</rss>
