<?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 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;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&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="nv"&gt;$CLAUDE_PRE_TOOL_DIFF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# captured 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="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;$CLAUDE_TOOL_INPUT&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&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;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.file_path'&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;$CLAUDE_TOOL_INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&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-log.jsonl&lt;/code&gt; and surfaced the next session. The hook doesn't exit 2 here — the turn is already over — but the log 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>
