<?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: the kilted dev</title>
    <description>The latest articles on DEV Community by the kilted dev (@thekilteddev).</description>
    <link>https://dev.to/thekilteddev</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%2F4030958%2Fc40140e1-f5ac-4668-9b6b-166ea0ddc762.png</url>
      <title>DEV Community: the kilted dev</title>
      <link>https://dev.to/thekilteddev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thekilteddev"/>
    <language>en</language>
    <item>
      <title>Not the integrated GPU</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sat, 17 Oct 2026 07:55:53 +0000</pubDate>
      <link>https://dev.to/thekilteddev/not-the-integrated-gpu-1b13</link>
      <guid>https://dev.to/thekilteddev/not-the-integrated-gpu-1b13</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/nU3qaBlULGc" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;Field notes on a laptop display-topology migration, still mid-journey.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two sockets, one controller
&lt;/h2&gt;

&lt;p&gt;Three monitors, one laptop, and one USB dock old enough to be a bottleneck. Two of the three&lt;br&gt;
monitors ran through it: a 4K panel and an ultrawide, both sharing one USB link with the keyboard,&lt;br&gt;
the mouse, and Ethernet. The third monitor, a second 4K panel, was already wired natively straight&lt;br&gt;
to the discrete GPU over HDMI, no dock involved.&lt;/p&gt;

&lt;p&gt;The laptop has two USB-C sockets, and the working assumption going in was that a monitor plugged&lt;br&gt;
into either one would land on the integrated graphics, the way a lot of thin-and-light laptops&lt;br&gt;
route USB-C video. The two ports look identical. They aren't quite: one carries power delivery up&lt;br&gt;
to 100W and DisplayPort 2.1, the other tops out at DisplayPort 1.4 with no power delivery at all.&lt;br&gt;
Both are wired to the same USB controller underneath, the only one this laptop has. A different&lt;br&gt;
configuration of the same chassis swaps one of those ports for Thunderbolt; this one doesn't, and&lt;br&gt;
that was confirmed by checking the controller list directly rather than trusting the spec sheet for&lt;br&gt;
this exact model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The port routes to the discrete GPU, not the integrated one
&lt;/h2&gt;

&lt;p&gt;Moving the ultrawide monitor from the old dock onto the power-delivery USB-C port — native&lt;br&gt;
DisplayPort Alt Mode, the mode that carries real DisplayPort video directly over a USB-C connector,&lt;br&gt;
no dock in between — settled the assumption by measurement instead of guesswork. The monitor came&lt;br&gt;
up attached to the discrete GPU. Not the integrated graphics chip doing double duty on the internal&lt;br&gt;
panel and the USB-C port both, which was the original assumption. A separate, dedicated card, the&lt;br&gt;
same one already driving one of the 4K monitors over HDMI.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Display&lt;/th&gt;
&lt;th&gt;Was driven by&lt;/th&gt;
&lt;th&gt;Now driven by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Internal panel (2560×1600 · 165Hz)&lt;/td&gt;
&lt;td&gt;integrated GPU&lt;/td&gt;
&lt;td&gt;integrated GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4K panel, HDMI (3840×2160 · 60Hz)&lt;/td&gt;
&lt;td&gt;discrete GPU&lt;/td&gt;
&lt;td&gt;discrete GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ultrawide (3440×1440 · 60Hz → 100Hz)&lt;/td&gt;
&lt;td&gt;the dock&lt;/td&gt;
&lt;td&gt;discrete GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4K panel, dock (3840×2160 · 60Hz)&lt;/td&gt;
&lt;td&gt;the dock&lt;/td&gt;
&lt;td&gt;the dock (cable ordered)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four displays across three adapters, read out of &lt;code&gt;dxdiag&lt;/code&gt; before and after the first move. Only the&lt;br&gt;
ultrawide changed hands. The second 4K panel is still on the dock, waiting on a cable.&lt;/p&gt;

&lt;p&gt;The other USB-C port hasn't been tested the same way yet. The reasonable guess is that it behaves&lt;br&gt;
the same, since both ports sit on the same controller, but a guess is exactly the thing this whole&lt;br&gt;
exercise exists to replace with a measurement, so it stays a guess until it's checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Faster the moment the dock was out of the path
&lt;/h2&gt;

&lt;p&gt;With the ultrawide on its own native connection, &lt;code&gt;dxdiag&lt;/code&gt; showed it running at 3440×1440. The old&lt;br&gt;
dock connection had topped out at 60Hz for that monitor, full stop. There was no higher option to&lt;br&gt;
select. The panel itself supports 100Hz; the dock's video path never exposed anything past 60,&lt;br&gt;
because it wasn't sending real DisplayPort signal at all — it was compressing the whole desktop into&lt;br&gt;
a video stream and shipping that over USB.&lt;/p&gt;

&lt;p&gt;The refresh rate didn't jump to 100Hz on its own, though. Straight after the re-cable, the display&lt;br&gt;
still reported 59.97Hz, because Windows carries a monitor's previous setting forward across a&lt;br&gt;
different physical connection rather than picking the best one available. The "Choose a refresh&lt;br&gt;
rate" dropdown listed nine options, four of them marked with an asterisk meaning the adapter&lt;br&gt;
synthesised them rather than the monitor actually supporting them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Refresh rate&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;99.90 Hz&lt;/td&gt;
&lt;td&gt;unmarked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75.03 Hz&lt;/td&gt;
&lt;td&gt;adapter-synthesised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;72.19 Hz&lt;/td&gt;
&lt;td&gt;adapter-synthesised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70.07 Hz&lt;/td&gt;
&lt;td&gt;adapter-synthesised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 Hz&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;59.97 Hz&lt;/td&gt;
&lt;td&gt;selected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;59.94 Hz&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;56.25 Hz&lt;/td&gt;
&lt;td&gt;adapter-synthesised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 Hz&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Picking the unmarked 99.90Hz by hand got a 100Hz reading in &lt;code&gt;dxdiag&lt;/code&gt; afterward.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A cable upgrade doesn't come with an automatic refresh-rate upgrade attached. Somebody has to go&lt;br&gt;
into the settings and pick it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A tool that reported the wrong monitor entirely
&lt;/h2&gt;

&lt;p&gt;One check along the way turned up something worth flagging on its own, separate from the dock:&lt;br&gt;
&lt;code&gt;Win32_VideoController&lt;/code&gt;, a common way of asking Windows what resolution and refresh rate a graphics&lt;br&gt;
adapter is currently running, gave a confidently wrong answer in this exact multi-monitor setup. It&lt;br&gt;
reported the&lt;br&gt;
integrated graphics chip as running the ultrawide's resolution at 60Hz. The integrated chip was&lt;br&gt;
never connected to that monitor at all. It was driving the laptop's own internal screen, at a&lt;br&gt;
completely different resolution and refresh rate. &lt;code&gt;dxdiag&lt;/code&gt;, read directly, had the real picture:&lt;br&gt;
internal panel on the integrated chip, ultrawide hanging off the old dock. The first tool wasn't&lt;br&gt;
lying about a number — it was reporting the wrong adapter's numbers as if they belonged to a monitor&lt;br&gt;
they'd never touched.&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;Reported&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Win32_VideoController&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Integrated graphics, 3440×1440 at 60Hz — the ultrawide's mode, on an adapter that was never wired to it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dxdiag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Integrated graphics, 2560×1600 at 165Hz — the laptop's own internal panel, which is what it was actually driving&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same adapter, read two ways, on the same machine on the same day. I'd check with a second tool&lt;br&gt;
before trusting a report like that in any setup with more than one screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bandwidth math the dock was quietly doing
&lt;/h2&gt;

&lt;p&gt;The old dock's USB link carries something like 4 gigabits a second, shared across both monitors, the&lt;br&gt;
keyboard and mouse, and the network connection. A single 4K panel at 60Hz, uncompressed, needs&lt;br&gt;
roughly 12.8 gigabits a second on its own. Two monitors, both routed through that one dock, meant&lt;br&gt;
somewhere around 25 gigabits of raw pixel data getting squeezed into a 4-gigabit pipe, continuously,&lt;br&gt;
the entire time both screens were on. Something has to give, and what gave was a real-time&lt;br&gt;
compression pass running in software the whole time, on hardware never asked whether it minded.&lt;/p&gt;

&lt;p&gt;Native DisplayPort Alt Mode over USB-C, by contrast, has room to spare: the standard this laptop's&lt;br&gt;
faster port supports moves tens of gigabits a second, more than enough for one monitor at its native&lt;br&gt;
resolution and refresh rate with nothing left to compress. Once the ultrawide moved off the dock,&lt;br&gt;
its own share of that math simply stopped being a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually stands
&lt;/h2&gt;

&lt;p&gt;One monitor is done: natively connected, correctly identified as running off the discrete GPU, and&lt;br&gt;
finally at the refresh rate it was always capable of. A cable for the second monitor, DisplayPort&lt;br&gt;
this time rather than the general-purpose USB-C to USB-C cable currently in use, has been ordered&lt;br&gt;
but not yet installed. Once it lands, the old dock drops out of the video path entirely and keeps&lt;br&gt;
one job: charging and networking, which is what a general-purpose USB link is good at.&lt;/p&gt;

&lt;p&gt;Nothing here was fixed by buying better hardware. The laptop, the monitors, and the faster port were&lt;br&gt;
all already sitting there. The fix was checking which port actually did what, instead of assuming&lt;br&gt;
the label on the box.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/not-the-integrated-gpu/" rel="noopener noreferrer"&gt;thekilted.dev/not-the-integrated-gpu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>displayport</category>
      <category>usbc</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Blocked, not rejected</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:29:02 +0000</pubDate>
      <link>https://dev.to/thekilteddev/blocked-not-rejected-1bbn</link>
      <guid>https://dev.to/thekilteddev/blocked-not-rejected-1bbn</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/QzpmuLujYbk" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;The most capable-looking command in a fifty-seven-item tool catalogue turned out to be five&lt;br&gt;
function calls to a server this setup doesn't run. No trial was run, because narrating what a&lt;br&gt;
missing server would do isn't a test. The verdict needed a name of its own, and blocked fit.&lt;/p&gt;

&lt;p&gt;SuperClaude is a configuration layer for Claude Code, installed as an add-on rather than built in:&lt;br&gt;
slash commands, agents and behavioural modes, fifty-seven items in the catalogue in total. One of&lt;br&gt;
them read as the single most capable thing in it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pm-agent&lt;/code&gt;, invoked as &lt;code&gt;/sc:pm&lt;/code&gt;, read on paper like a full session-management layer. Cross-session&lt;br&gt;
memory that survives between conversations. Checkpointing every thirty minutes. Self-evaluation, so&lt;br&gt;
the agent could ask itself whether it was actually done before reporting done. The kind of feature&lt;br&gt;
list that makes a reader comparing AI coding tools stop and read it twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;

&lt;p&gt;The catalogue's own summary of &lt;code&gt;/sc:pm&lt;/code&gt; never says how any of that actually works. Reading the full&lt;br&gt;
source (&lt;code&gt;agents/pm-agent.md&lt;/code&gt; and &lt;code&gt;commands/pm.md&lt;/code&gt; in the SuperClaude repository) does.&lt;/p&gt;

&lt;p&gt;Every one of those mechanisms turned out to be a literal call to a function on a Serena server.&lt;br&gt;
Serena is an MCP (Model Context Protocol) server, a separate program that Claude Code connects to&lt;br&gt;
for extra tools. The calls are &lt;code&gt;list_memories()&lt;/code&gt;, &lt;code&gt;read_memory()&lt;/code&gt;, &lt;code&gt;write_memory()&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;think_about_task_adherence()&lt;/code&gt;, &lt;code&gt;think_about_whether_you_are_done()&lt;/code&gt;. Cross-session memory was a&lt;br&gt;
named function call to a server. So was self-evaluation: another named call to the same server.&lt;br&gt;
None of those functions exist without a Serena MCP server actually connected, and at the time of&lt;br&gt;
the review this setup's &lt;code&gt;.mcp.json&lt;/code&gt; defined no servers at all. None, checked directly in the file&lt;br&gt;
rather than assumed.&lt;/p&gt;

&lt;p&gt;The dependency did not stop there. For full behaviour, &lt;code&gt;/sc:pm&lt;/code&gt; also wanted seven more MCP&lt;br&gt;
servers (eight, all told, all absent) loaded dynamically through a third-party Docker gateway,&lt;br&gt;
&lt;code&gt;airis-mcp-gateway&lt;/code&gt;. Reading more of the command's own source only lengthened the list of things&lt;br&gt;
that would need to&lt;br&gt;
exist first, never shortened it.&lt;/p&gt;

&lt;p&gt;To run the same check on any tool, search its command files for calls to functions that need a&lt;br&gt;
connected server, then compare that list with your own MCP configuration.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/SuperClaude-Org/SuperClaude_Framework" rel="noopener noreferrer"&gt;github.com/SuperClaude-Org/SuperClaude_Framework&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A repeat
&lt;/h2&gt;

&lt;p&gt;The shape had shown up in the framework before. An earlier command, &lt;code&gt;/reflect&lt;/code&gt;,&lt;br&gt;
had already been excluded from an earlier pass for the identical reason. Its own file states&lt;br&gt;
plainly, in its own "Will Not" section:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Operate without proper Serena MCP integration and reflection tool access.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same disqualifying issue, same missing server, a different command. Once is a fact about one&lt;br&gt;
command. Twice is a fact about the framework's own design pattern: describe a capability in the&lt;br&gt;
documentation, implement it as a call to infrastructure the reader is never told they need.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second objection
&lt;/h2&gt;

&lt;p&gt;Set the missing server aside, and there was still a second, unrelated problem. The command's own&lt;br&gt;
file declared itself the default operating foundation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The DEFAULT operating foundation that runs automatically at every session start... the default&lt;br&gt;
entry point for all interactions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Auto-delegating every request to sub-agents before a human ever saw it. That was a takeover of a&lt;br&gt;
workflow already in place, duplicating this setup's own working four-file lifecycle (&lt;code&gt;NOTES.md&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;MEMORY.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;DECISIONS.md&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;A different project in the same workspace had already declined the exact same shape for the&lt;br&gt;
framework's Deep Research mode: an ungated feature that wanted to become the default way every&lt;br&gt;
session behaved, rather than a tool invoked on request. This setup read that rule, then reached the&lt;br&gt;
same objection on pm-agent and checked it against that record. The missing server was the&lt;br&gt;
independent finding; the second objection leaned on that earlier record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;No trial was run. On paper the command looked capable, but a trial without the missing server&lt;br&gt;
would only narrate what &lt;code&gt;write_memory()&lt;/code&gt; would do, with nothing real underneath the narration.&lt;/p&gt;

&lt;p&gt;A trial without the server would be a plausible performance of a test.&lt;/p&gt;

&lt;p&gt;This setup keeps a standing rule for exactly that failure mode: trust the artifact over the summary.&lt;br&gt;
Reading &lt;code&gt;pm-agent&lt;/code&gt;'s actual source instead of its one-line catalogue description is what caught the gap&lt;br&gt;
before a fake trial could run.&lt;/p&gt;

&lt;p&gt;So the verdict on record was not rejected. It was blocked.&lt;/p&gt;

&lt;p&gt;A blocked verdict names the specific missing thing and stays open to being wrong about it. If a&lt;br&gt;
Serena MCP server is ever installed here for an unrelated reason, this is worth a second look.&lt;br&gt;
The session-takeover design would still need weighing on its own merits. A smaller, scoped-down&lt;br&gt;
version of the trial was floated too and turned down; that was the project owner's call, and no&lt;br&gt;
second missing dependency lay behind it. A rejected verdict would have closed that door for good.&lt;br&gt;
This one stayed open on purpose.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/blocked-not-rejected/" rel="noopener noreferrer"&gt;thekilted.dev/blocked-not-rejected&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>aiagents</category>
      <category>claudecode</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>A Claude Code Stop hook that makes Claude show the full file path</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Wed, 07 Oct 2026 06:58:47 +0000</pubDate>
      <link>https://dev.to/thekilteddev/a-claude-code-stop-hook-that-makes-claude-show-the-full-file-path-4lk3</link>
      <guid>https://dev.to/thekilteddev/a-claude-code-stop-hook-that-makes-claude-show-the-full-file-path-4lk3</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/LD_rZm5MwWs" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Claude Code answered &lt;code&gt;Run the tests with scripts\check.py.&lt;/code&gt; I needed &lt;code&gt;C:\proj\scripts\check.py&lt;/code&gt;. (I staged that exchange: I asked for the short form so the hook had something to catch.)&lt;/p&gt;

&lt;p&gt;A single path is easy to work out. A list is harder. In the folder for one video episode, Claude listed six files by short name and I could not tell where any of them were.&lt;/p&gt;

&lt;p&gt;I had already put a line in &lt;code&gt;CLAUDE.md&lt;/code&gt;, the file where you write standing instructions for Claude:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Always give absolute paths, never relative ones.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude kept slipping. So I wrote a Stop hook that checks every reply and sends it back when it names a file by a relative path. The code is in the repo &lt;a href="https://github.com/thekilteddev/claude-code-stop-hook-full-path" rel="noopener noreferrer"&gt;claude-code-stop-hook-full-path&lt;/a&gt;, and the video is linked at the end. It runs on Windows and Linux. I have not tried it on macOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;On Windows, Claude answers &lt;code&gt;Run the tests with scripts\check.py.&lt;/code&gt; The hook blocks the reply, and Claude answers again: &lt;code&gt;Run the tests with C:\proj\scripts\check.py.&lt;/code&gt; That was Claude Code 2.1.284 on Haiku 4.5, in a project at &lt;code&gt;C:\proj&lt;/code&gt;. The Windows part of the video uses my own hook. The one in the repo is a shorter stand-in with a different message, and it does the same job.&lt;/p&gt;

&lt;p&gt;On Linux, the stand-in hook ran in a &lt;code&gt;node:22-slim&lt;/code&gt; container with Claude Code 2.1.286 and a project at &lt;code&gt;/home/dev/proj&lt;/code&gt;. Claude listed six files by short path. The hook replied &lt;code&gt;Full paths only. Your reply has short paths:&lt;/code&gt; and Claude answered again with the full paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;episode/script.md     -&amp;gt;  /home/dev/proj/episode/script.md
episode/clip-01.mp4   -&amp;gt;  /home/dev/proj/episode/clip-01.mp4
episode/captions.srt  -&amp;gt;  /home/dev/proj/episode/captions.srt
episode/clip-02.mp4   -&amp;gt;  /home/dev/proj/episode/clip-02.mp4
episode/notes.md      -&amp;gt;  /home/dev/proj/episode/notes.md
episode/cover.png     -&amp;gt;  /home/dev/proj/episode/cover.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What a Stop hook receives and what it can send back
&lt;/h2&gt;

&lt;p&gt;A Stop hook is a command Claude Code runs each time Claude finishes a reply. It gets one JSON object on stdin. Two of its fields matter here. &lt;code&gt;last_assistant_message&lt;/code&gt; is the text of the final reply. &lt;code&gt;stop_hook_active&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; when Claude Code is already continuing because of a stop hook.&lt;/p&gt;

&lt;p&gt;The hook answers on stdout. Printing &lt;code&gt;{"decision": "block", "reason": "..."}&lt;/code&gt; keeps Claude from stopping, and &lt;code&gt;reason&lt;/code&gt; tells it why it should continue. Printing nothing lets the reply stand. The &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks reference&lt;/a&gt; lists exit code 2 with the message on stderr as another way to block. This hook uses the JSON form.&lt;/p&gt;

&lt;p&gt;Claude Code allows eight stop-hook continuations in a row before it overrides the next block and ends the turn. The hook checks &lt;code&gt;stop_hook_active&lt;/code&gt; anyway: if the rewrite still has a short path, it lets the reply through instead of blocking again.&lt;/p&gt;

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

&lt;p&gt;This is the repo's stand-in hook, the one that ran in the Linux container. It is 89 lines of Python with no dependencies.&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="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Claude Code Stop hook: when the last reply names a file by a short path, block it and ask for the full path.

Written for Windows, macOS and Linux; tested on Windows and Linux. Reads the Stop-hook JSON payload on stdin and prints
{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: ...} when a reply has a short path, nothing otherwise.
Only paths inside backticks that end in a file extension are checked. A directory without an
extension, or a path outside backticks, is not flagged.
Run `python stop_hook_full_path.py --selftest` to check the cases below.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;TICKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;`([^`\n]{1,200})`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# absolute on Windows (C:\ or C:/), POSIX or UNC (leading / or \), home (~), or a URL
&lt;/span&gt;&lt;span class="n"&gt;ABSOLUTE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^(?:[A-Za-z]:[\\/]|[\\/]|~|[a-zA-Z][a-zA-Z0-9+.\-]*://)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SEGMENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[\w.\-{}, ]+$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# braces and commas allowed: src/{a,b}.ts is one short path
&lt;/span&gt;&lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\.[A-Za-z0-9]{1,6}$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;short_paths&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;found&lt;/span&gt; &lt;span class="o"&gt;=&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;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TICKS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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;ABSOLUTE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[\\/]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[\\/]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&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;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SEGMENT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&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;found&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;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;payload&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# already blocked once: let Claude stop rather than loop
&lt;/span&gt;    &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;short_paths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;last_assistant_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;shown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&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;`&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&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; (+&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; more)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&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;decision&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;block&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;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;Full paths only. Your reply has short paths: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;shown&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Rewrite them as absolute paths.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Strip EVERY leading byte order mark: a UTF-8 pipe from Windows PowerShell can send two,
&lt;/span&gt;    &lt;span class="c1"&gt;# and the "utf-8-sig" codec removes only one.
&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;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\ufeff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;selftest&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="o"&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;`episode/clip-01.mp4`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&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;`/home/dev/proj/episode/clip-01.mp4`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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;`/Users/dev/proj/episode/clip-01.mp4`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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;`C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;proj&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;episode&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;clip-01.mp4`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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;`episode&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;clip-01.mp4`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&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;`src/{a,b}.ts`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&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;Press `q`, then open `src/a.ts` and `src/b.ts`.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# a one-character code span must not break the pairing
&lt;/span&gt;        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;`./src/app.ts`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&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;`~/proj/app.ts`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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;`clip-01.mp4`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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;`anthropics/claude-code`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&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;no backticks: episode/clip-01.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&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;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;short_paths&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;assert&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&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;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_assistant_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;`episode/a.md`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\xef\xbb\xbf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\xef\xbb\xbf\xef\xbb\xbf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# none, one BOM, two BOMs
&lt;/span&gt;        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;decision&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="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_assistant_message&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;`a/b.ts`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;six&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&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;`episode/f&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.md`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(+4 more)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_assistant_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;six&lt;/span&gt;&lt;span class="p"&gt;})[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all self-tests passed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--selftest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;selftest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load&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="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;buffer&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="k"&gt;except&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;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&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="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It flags a path only when it sits inside backticks, contains a &lt;code&gt;/&lt;/code&gt; or a &lt;code&gt;\&lt;/code&gt;, and ends in a file extension. Anything already absolute is skipped: &lt;code&gt;C:\&lt;/code&gt; or &lt;code&gt;C:/&lt;/code&gt;, a leading &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;\&lt;/code&gt;, &lt;code&gt;~&lt;/code&gt;, or a URL. These are the self-test's own cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reply contains&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;episode/clip-01.mp4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flagged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;episode\clip-01.mp4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flagged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;./src/app.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flagged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/{a,b}.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flagged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/home/dev/proj/episode/clip-01.mp4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;left alone: absolute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;C:\proj\episode\clip-01.mp4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;left alone: absolute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/proj/app.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;left alone: absolute (home)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clip-01.mp4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;left alone: a bare file name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anthropics/claude-code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;left alone: missing extension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;episode/clip-01.mp4 without backticks&lt;/td&gt;
&lt;td&gt;left alone: outside backticks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The message is built in &lt;code&gt;decision()&lt;/code&gt;. It names the first two short paths and counts the rest as &lt;code&gt;(+N more)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python stop_hook_full_path.py --selftest&lt;/code&gt; runs the built-in cases and prints &lt;code&gt;all self-tests passed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding it to Claude Code
&lt;/h2&gt;

&lt;p&gt;Hooks go in a Claude Code settings file. Which one depends on how far the hook should reach:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.claude/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every project on your machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.local.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one project, and is meant to stay out of git&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.claude/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;everyone who uses the repository, so use it only if the script is in the repository too&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the file already has a &lt;code&gt;hooks&lt;/code&gt; object, merge the &lt;code&gt;Stop&lt;/code&gt; entry into it. On Windows:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;C:/Users/you/hooks/stop_hook_full_path.py&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;On Linux:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3 /home/you/hooks/stop_hook_full_path.py"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;Both paths are examples. Use the absolute path to wherever you saved the file, and make sure &lt;code&gt;python&lt;/code&gt; (or &lt;code&gt;python3&lt;/code&gt;) is on the &lt;code&gt;PATH&lt;/code&gt; of the shell Claude Code runs it in. On Windows that is Git Bash, or PowerShell if Git Bash is not installed. &lt;code&gt;timeout&lt;/code&gt; is in seconds. Type &lt;code&gt;/hooks&lt;/code&gt; in Claude Code to see the hook listed. The hooks reference says edits to settings files are normally picked up automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 1: a byte order mark made the hook exit without an error
&lt;/h2&gt;

&lt;p&gt;The check at that point was a short script. It already used &lt;code&gt;decision&lt;/code&gt; and &lt;code&gt;reason&lt;/code&gt;, because the field mistake below came first:&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;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&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="k"&gt;try&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_assistant_message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;`((?![A-Za-z]:)[\w.-]+\\[^`]+)`&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&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;bad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&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="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&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;block&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;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;Use absolute paths, not: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bad&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="si"&gt;}&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;Fed the same payload from two shells, it behaved two ways in the video. In Bash, &lt;code&gt;python check_rules_v1.py &amp;lt; payload.json&lt;/code&gt; printed the block decision. In Windows PowerShell 5.1, &lt;code&gt;Get-Content payload.json | python check_rules_v1.py&lt;/code&gt; printed nothing and reported no error. That depends on the console: on a default console with input code page 850, I got the block decision from the same command. The two settings that decide it are below. &lt;code&gt;check_rules_v1.py&lt;/code&gt;, &lt;code&gt;peek.py&lt;/code&gt; (used below) and &lt;code&gt;payload.json&lt;/code&gt;, the sample hook input, are in the repo's &lt;code&gt;demo-bom&lt;/code&gt; folder.&lt;/p&gt;

&lt;p&gt;The cause is in the first bytes Python reads. A byte order mark is the three bytes &lt;code&gt;EF BB BF&lt;/code&gt; that some tools put at the very start of UTF-8 text, and &lt;code&gt;json.loads&lt;/code&gt; raises on one. When PowerShell pipes &lt;code&gt;payload.json&lt;/code&gt; to Python it can add one. My &lt;code&gt;except Exception: sys.exit(0)&lt;/code&gt; swallowed the error, so the hook exited quietly and blocked nothing. That looks the same as a hook with nothing to flag.&lt;/p&gt;

&lt;p&gt;The fix reads bytes, decodes them as UTF-8, and strips every leading mark before parsing. It strips every mark, not one, because the pipe can add two (the list below shows when):&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;buffer&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="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\ufeff&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;Two settings decide how many marks Python receives. &lt;code&gt;$OutputEncoding&lt;/code&gt; is the encoding PowerShell uses when it pipes data into a program. The console's input code page is the numbered character set a Windows console uses for the text it reads and writes. A second script, &lt;code&gt;peek.py&lt;/code&gt;, prints the first bytes Python receives as hex, and &lt;code&gt;7B 22&lt;/code&gt; is &lt;code&gt;{"&lt;/code&gt;, the start of the file. With both settings on UTF-8, Python received &lt;code&gt;EF BB BF EF BB BF 7B 22 68&lt;/code&gt;: two marks, and the check printed nothing.&lt;/p&gt;

&lt;p&gt;First nine bytes Python receives, by console input code page and &lt;code&gt;$OutputEncoding&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;850   / default   7B 22 68 6F 6F 6B 5F 65 76   v1 blocks
850   / UTF-8     EF BB BF 7B 22 68 6F 6F 6B   v1 silent (one byte order mark)
65001 / default   EF BB BF 7B 22 68 6F 6F 6B   v1 silent (one byte order mark)
65001 / UTF-8     EF BB BF EF BB BF 7B 22 68   v1 silent (two byte order marks)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured with &lt;code&gt;Get-Content payload.json | python peek.py&lt;/code&gt;. &lt;code&gt;EF BB BF&lt;/code&gt; is a UTF-8 byte order mark. &lt;code&gt;7B 22&lt;/code&gt; is &lt;code&gt;{"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I measured this in non-interactive &lt;code&gt;powershell.exe -NoProfile -File&lt;/code&gt; runs on Windows PowerShell 5.1, setting &lt;code&gt;[Console]::InputEncoding&lt;/code&gt; inside the script. I did not test PowerShell 7. Run &lt;code&gt;peek.py&lt;/code&gt; against your own pipe before you trust a hook there.&lt;/p&gt;

&lt;p&gt;I tested this by feeding the hook from PowerShell myself. I have not measured what Claude Code itself writes to a hook's stdin, so the hook strips the mark either way.&lt;/p&gt;

&lt;p&gt;Reading text with &lt;code&gt;sys.stdin.read()&lt;/code&gt; is not enough on Windows. &lt;code&gt;PYTHONIOENCODING&lt;/code&gt; is an environment variable that overrides the encoding Python uses for stdin and stdout. When it is not set, Python 3.11 decodes a pipe with the system code page (cp1252 on my machine), and there the mark arrives as three other characters, so &lt;code&gt;lstrip&lt;/code&gt; finds nothing. Setting &lt;code&gt;PYTHONUTF8=1&lt;/code&gt;, Python's UTF-8 mode, changes that as well.&lt;/p&gt;

&lt;p&gt;I had that wrong at first. The demo's first fix used &lt;code&gt;sys.stdin.read()&lt;/code&gt;. It passed in a session where &lt;code&gt;PYTHONIOENCODING&lt;/code&gt; was set to UTF-8 and failed in a clean one. The repo hook reads bytes the same way, but its first copy used Python's &lt;code&gt;utf-8-sig&lt;/code&gt; codec, which removes one mark, so the two-mark case still failed the same silent way. The self-test now sends none, one and two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2: the field name
&lt;/h2&gt;

&lt;p&gt;Before that, my very first version printed &lt;code&gt;systemMessage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"systemMessage": "Use absolute paths, not: scripts\\check.py"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hooks reference describes &lt;code&gt;systemMessage&lt;/code&gt; as a warning message shown to the user. Claude did not react to it, so nothing changed. The change that worked was two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    print(json.dumps({"systemMessage":
-                      f"Use absolute paths, not: {bad[0]}"}))
&lt;/span&gt;&lt;span class="gi"&gt;+    print(json.dumps({"decision": "block",
+                      "reason": f"Use absolute paths, not: {bad[0]}"}))
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not keep that first version. The diff is a reconstruction of it, made for the video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 3: a check that rejected braces
&lt;/h2&gt;

&lt;p&gt;One reply used brace shorthand: &lt;code&gt;social\clip-0{1,2}-9x16.mp4&lt;/code&gt; means &lt;code&gt;social\clip-01-9x16.mp4&lt;/code&gt; and &lt;code&gt;social\clip-02-9x16.mp4&lt;/code&gt; in one name. My old pattern for a path segment covered letters, digits, dots and dashes. I did not keep it, so this one-line check does the same job, and a brace matches none of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python -c "import re; print(re.match(r'^[\w.\-]+$', 'clip-0{1,2}-9x16.mp4'))"
None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name failed the pattern, so the check ignored the reply. The pattern in the repo's hook allows braces and commas, &lt;code&gt;^[\w.\-{}, ]+$&lt;/code&gt;, and the self-test includes &lt;code&gt;src/{a,b}.ts&lt;/code&gt;. In my own hook, &lt;code&gt;looks_like_relative_path&lt;/code&gt; returned &lt;code&gt;True&lt;/code&gt; for the same name after the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops
&lt;/h2&gt;

&lt;p&gt;It reads the last reply only. It sees paths inside backticks and nothing else, so a path Claude writes without backticks gets through. A path with a line number, &lt;code&gt;src/app.ts:12&lt;/code&gt;, is not flagged. A code span such as &lt;code&gt;python/3.11&lt;/code&gt; is flagged although it is not a path. It has run on one Windows machine and in one Linux container, and not on macOS. The byte order mark behaviour above is what I measured on that one machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code and the video
&lt;/h2&gt;

&lt;p&gt;The hook, its self-test, both byte order mark scripts and the Linux demo are in &lt;a href="https://github.com/thekilteddev/claude-code-stop-hook-full-path" rel="noopener noreferrer"&gt;claude-code-stop-hook-full-path&lt;/a&gt;, under the MIT licence. The video walks through the same hook: &lt;a href="https://youtu.be/LD_rZm5MwWs" rel="noopener noreferrer"&gt;https://youtu.be/LD_rZm5MwWs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is an independent project, not affiliated with or endorsed by Anthropic.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>hooks</category>
      <category>python</category>
      <category>windows</category>
    </item>
    <item>
      <title>The verification tax</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sat, 03 Oct 2026 12:28:00 +0000</pubDate>
      <link>https://dev.to/thekilteddev/the-verification-tax-kn2</link>
      <guid>https://dev.to/thekilteddev/the-verification-tax-kn2</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/O5cFv2Qn59U" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;A session narrating its own work inline, no delegation, spent 78% of its context window across&lt;br&gt;
two long stretches with almost no back-and-forth from whoever had asked for it. A separate&lt;br&gt;
session, same shape, burned over a million tokens in one pass. Neither had done anything wrong.&lt;br&gt;
Both had already made the right call on which model to use. They just ran the work themselves&lt;br&gt;
instead of handing it off, and talked through every step on the way.&lt;/p&gt;

&lt;p&gt;The fix looked obvious once it had a name: a task can be the right tier and still be the wrong&lt;br&gt;
thing to run inline. Long, low-interaction work burns the same resource, the controlling&lt;br&gt;
session's own context window, no matter which model is doing it or what that model costs.&lt;br&gt;
Background it. Default to a brief checkpoint instead of a running commentary. Cheap rule, easy to&lt;br&gt;
state, and it should have been the end of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it on something real
&lt;/h2&gt;

&lt;p&gt;The next real chance to watch the rule work was an actual five-task build: a search feature&lt;br&gt;
threaded through several existing functions, tested live in a browser as each piece landed. Every&lt;br&gt;
task went to a subagent with a full brief and a strict report contract: done, done with concerns,&lt;br&gt;
blocked, pick one, or put the detail in a file rather than the reply. Exactly what the rule asked&lt;br&gt;
for. Implementation never ran inline.&lt;/p&gt;

&lt;p&gt;The controlling session's own context climbed anyway. Not dramatically, four points over about&lt;br&gt;
three hours, but steadily, the whole time, driven by the same thing the rule was written to stop:&lt;br&gt;
long stretches of low-interaction activity, just moved to a different part of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What moved into the gap
&lt;/h2&gt;

&lt;p&gt;Dispatching the implementation only covers what happens inside the dispatch. It does nothing for&lt;br&gt;
two costs that stay with the controller regardless of how well the dispatch is written.&lt;/p&gt;

&lt;p&gt;One is the brief itself. A good dispatch spec spells out its constraints, the known gotchas, the&lt;br&gt;
exact test sequences, on purpose, so the thing doing the work doesn't have to guess. That text&lt;br&gt;
gets paid for twice: once to write, once for the controller to carry in its own history after&lt;br&gt;
sending it.&lt;/p&gt;

&lt;p&gt;The other is the check afterward. The controlling session read every report against the actual&lt;br&gt;
diff, and independently re-tested several claims rather than taking them on trust. None of that&lt;br&gt;
was delegated. It moved from doing the work to checking it, a category of cost the original rule&lt;br&gt;
never named.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The brief&lt;/td&gt;
&lt;td&gt;Constraints, known gotchas, exact test sequences. The controller writes it, then carries it for the rest of the run.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The work&lt;/td&gt;
&lt;td&gt;One subagent per task, each with a window of its own. The only phase the rule ever reached.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The check&lt;/td&gt;
&lt;td&gt;Every report read against the diff, several claims re-run. All of it back on the controller.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three phases in the build. Only the middle one ever left the controlling session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check wasn't wasted
&lt;/h2&gt;

&lt;p&gt;If the fix had been "verify less," the numbers would look better and the build would be worse. The&lt;br&gt;
verification pass caught three real problems in that one run. Twice, a report said something&lt;br&gt;
hadn't worked when the code was fine and the test had failed for an unrelated reason. Once, a&lt;br&gt;
report went quiet about a step it had skipped rather than saying so. None of the three was an&lt;br&gt;
actual defect in the code. Someone caught all three only by checking instead of trusting the&lt;br&gt;
report, and every one of them came out of work done by a cheaper, less careful process than the&lt;br&gt;
one doing the checking.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's the tax nobody had priced yet: route the doing to something cheaper, and the checking gets more necessary, not less.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the checking is exactly the kind of long, low-interaction work the rule already knew how to&lt;br&gt;
handle. Nobody had told it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quieter comparison, running at the same time
&lt;/h2&gt;

&lt;p&gt;Two other sessions were doing unrelated work in the same window, and both had something the build&lt;br&gt;
above didn't have: a real stopping point built into the task itself, an approval gate in one case,&lt;br&gt;
a direct question from whoever had started the session in the other. Neither ran anywhere near as&lt;br&gt;
long unsupervised.&lt;/p&gt;

&lt;p&gt;Both still climbed. Less than the fully autonomous build, meaningfully less, but nowhere near the&lt;br&gt;
order of magnitude a "no human in the loop" story would predict. A handful of points of the same&lt;br&gt;
resource, spent the same way, just less of it. Whatever drives the growth isn't only whether a&lt;br&gt;
person was watching. It looks closer to how much a session writes and reads on its own, per turn,&lt;br&gt;
regardless of who's nominally there.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Session&lt;/th&gt;
&lt;th&gt;Share of context held by message history&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Autonomous build&lt;/td&gt;
&lt;td&gt;39.2% (climbed from 35.3%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gated, direct question&lt;/td&gt;
&lt;td&gt;30.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gated, approval&lt;/td&gt;
&lt;td&gt;26.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What changed, and what didn't
&lt;/h2&gt;

&lt;p&gt;The rule that said background long inline work now covers long inline checking too: re-reading a&lt;br&gt;
report, or re-running a live test to confirm a claim independently. Same resource, same fix, just&lt;br&gt;
applied to a phase the rule hadn't reached yet.&lt;/p&gt;

&lt;p&gt;It doesn't make the checking optional. The three catches in the test run are the argument against&lt;br&gt;
that shortcut, not for it. The actual fix isn't spending less attention on verification. It's&lt;br&gt;
spending that attention somewhere that doesn't cost the one session that has to stay coherent for&lt;br&gt;
the whole build.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/the-verification-tax/" rel="noopener noreferrer"&gt;thekilted.dev/the-verification-tax&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>orchestration</category>
      <category>aiagents</category>
      <category>softwareengineering</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Still carrying the diagnosis</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Tue, 29 Sep 2026 06:39:24 +0000</pubDate>
      <link>https://dev.to/thekilteddev/still-carrying-the-diagnosis-4g96</link>
      <guid>https://dev.to/thekilteddev/still-carrying-the-diagnosis-4g96</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/BfM44aK6uCQ" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;Field notes from a local-model pipeline. A coding harness that had already proved itself went&lt;br&gt;
silent for an hour, and its replacement shipped the same session. The write-up explaining why&lt;br&gt;
needed a correction of its own.&lt;/p&gt;

&lt;p&gt;The plan was to have a local model write six Python modules of a real application, one at a time,&lt;br&gt;
each one gated on a test suite it had to make pass. The harness for that job was already chosen,&lt;br&gt;
already benchmarked, and already recorded as working.&lt;/p&gt;

&lt;p&gt;It produced nothing. Not bad code, no code. Zero files, for about an hour.&lt;/p&gt;

&lt;p&gt;What happened next is worth reading less for the fix, which took a session, than for what happened&lt;br&gt;
to the explanation afterward. The root cause was written down and spread into five documents, then&lt;br&gt;
disproved four days later by a controlled retest run from a different project. And the correction&lt;br&gt;
that replaced it, checked now against a benchmark run two weeks earlier, does not hold up against&lt;br&gt;
that evidence either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark behind it
&lt;/h2&gt;

&lt;p&gt;The tool was &lt;code&gt;little-coder&lt;/code&gt;, a small command-line coding agent tuned for small models, a thin&lt;br&gt;
wrapper over the &lt;code&gt;pi&lt;/code&gt; agent framework, installed from npm, pointed at a local model server.&lt;/p&gt;

&lt;p&gt;It hadn't been picked casually. On 4 July it ran a build-a-word-search-game benchmark on this&lt;br&gt;
machine and scored 8 out of 8, with a build time of 2.1 minutes, including a follow-up fix that&lt;br&gt;
didn't regress anything already working. Three files written to disk by the model's own write&lt;br&gt;
tool. Criterion one of that benchmark is literally "3 files saved to disk," and it passed.&lt;/p&gt;

&lt;p&gt;That run used &lt;code&gt;little-coder&lt;/code&gt; v1.9.11 against a 30-billion-parameter mixture-of-experts coding&lt;br&gt;
model, locally aliased &lt;code&gt;qwen3-coder-30b&lt;/code&gt;. That pairing matters later.&lt;/p&gt;

&lt;p&gt;The session built a launcher script around the winning recipe so no future session would have to&lt;br&gt;
rediscover the setup, including a prompt rule found the hard way: the word "tool" can't appear&lt;br&gt;
anywhere in the prompt, because even "command-line tool" trips a formatting failure in the model's&lt;br&gt;
output. That's the level of tuning already banked before any of this started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then nothing, silently
&lt;/h2&gt;

&lt;p&gt;The next day, given the six modules to delegate, the harness failed completely.&lt;/p&gt;

&lt;p&gt;The shape of the failure is the part worth sitting with. The command exited with status 0. It&lt;br&gt;
printed the model's reply. It wrote no file. The reply was raw &lt;code&gt;&amp;lt;function=write&amp;gt;&lt;/code&gt; XML, the model&lt;br&gt;
describing a write in text form instead of the harness receiving a structured call it could&lt;br&gt;
actually execute. Nothing parsed it, and nothing complained.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Exit 0 with no output written is the worst failure mode a delegated task can have, because every&lt;br&gt;
cheap way of checking says it worked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A caller reading the exit code sees success. A caller reading stdout sees a confident, well-formed&lt;br&gt;
response. Only a caller that goes and looks at the filesystem sees the truth.&lt;/p&gt;

&lt;p&gt;The first attempt to get modules out of it was a cheap orchestrator agent, given room to fix the&lt;br&gt;
harness itself. It ran for roughly an hour and about fifty tool calls, delivered zero modules, and&lt;br&gt;
along the way took an action nobody had sanctioned: a global &lt;code&gt;npm install -g&lt;/code&gt; that upgraded&lt;br&gt;
&lt;code&gt;little-coder&lt;/code&gt; from 1.9.11 to 1.9.13, mutating machine-wide tooling mid-run. That upgrade is the&lt;br&gt;
one variable that changed between the benchmark that worked and the delegation that didn't.&lt;/p&gt;

&lt;p&gt;The failure was then reproduced directly, by hand, rather than trusted from the agent's own&lt;br&gt;
report. That's the only reason any of what follows is checkable at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Depending on less
&lt;/h2&gt;

&lt;p&gt;Rather than downgrade or re-pin a command-line tool that had already proved it could change&lt;br&gt;
underneath a running project, the session wrote a replacement in the same sitting:&lt;br&gt;
&lt;code&gt;delegate-via-rest.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its design is the whole lesson. The model still writes all the code. What the driver removes is&lt;br&gt;
the model's need to call a tool at all. It sends the specification and the test file to the local&lt;br&gt;
server's chat endpoint, takes the reply, pulls out the largest fenced code block, writes that block&lt;br&gt;
to disk itself, runs pytest, and feeds any failure text back for another round. It talks only to&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That takes the entire tool-calling scaffold, the layer that had just failed, out of the critical&lt;br&gt;
path. Writing a file is something the harness can do reliably. Asking a model to emit a structured&lt;br&gt;
call that a third-party parser has to recognise depends on two things nobody in this pipeline&lt;br&gt;
controls. The driver keeps the model doing the one part only it can do, and does the mechanical&lt;br&gt;
part itself.&lt;/p&gt;

&lt;p&gt;Rounds ran 8 to 30 seconds on this hardware. Across the six modules, three passed their tests on&lt;br&gt;
the first attempt. The other three converged within a few rounds each, usually by sharpening the&lt;br&gt;
specification rather than coaxing the model. The finished application ran to 34 commits and 52&lt;br&gt;
passing tests.&lt;/p&gt;

&lt;p&gt;Two features were added later, and each one encodes a failure that had already happened. A&lt;br&gt;
&lt;code&gt;--selfcheck&lt;/code&gt; preflight runs a trivial task end to end through the real endpoint before any real&lt;br&gt;
dispatch, because a "verified working" harness had already broken silently between sessions. A&lt;br&gt;
&lt;code&gt;--probe&lt;/code&gt; flag runs a held-out adversarial test file after the acceptance tests go green, and its&lt;br&gt;
output is deliberately never shown to the model, because feeding it back would let the model&lt;br&gt;
overfit the probe the same way weak acceptance tests had already overfit twice. The driver also&lt;br&gt;
carries four distinct exit codes instead of a plain pass or fail, including one specifically for&lt;br&gt;
tests green but the held-out probe failed. After a silent exit-0 incident, none of that reads as&lt;br&gt;
over-engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause, as recorded
&lt;/h2&gt;

&lt;p&gt;The explanation written down was that version 1.9.13 no longer recognises the model's id, so it&lt;br&gt;
never attaches the tool-calling scaffold, so the model falls back to emitting raw XML.&lt;/p&gt;

&lt;p&gt;There was real evidence behind it. When the run failed, the harness printed a warning that the&lt;br&gt;
model wasn't found for the provider and it was falling back to a custom model id. A warning about&lt;br&gt;
the model id, sitting right next to a failure about the model not being wired up correctly, in a&lt;br&gt;
run whose only recent change was a version bump. It read as confirmation.&lt;/p&gt;

&lt;p&gt;That explanation made it into the trial write-up, the launcher's own header, the project's&lt;br&gt;
constraints file, its decisions log, and its tool registry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retest
&lt;/h2&gt;

&lt;p&gt;Four days later, a different project on the same machine retested the launcher as part of an&lt;br&gt;
unrelated tool inventory, and reproduced the failure in 46 seconds. Rather than stop at&lt;br&gt;
reproduction, it ran controls.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local server &lt;code&gt;/v1&lt;/code&gt; directly, with tools, non-streaming&lt;/td&gt;
&lt;td&gt;tool calls parsed correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local server &lt;code&gt;/v1&lt;/code&gt; directly, with tools, streaming&lt;/td&gt;
&lt;td&gt;tool calls parsed correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local server &lt;code&gt;/v1&lt;/code&gt; directly, no tools&lt;/td&gt;
&lt;td&gt;prose, no XML leak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;little-coder&lt;/code&gt; + &lt;code&gt;qwen3-coder-30b&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;raw XML, no file written&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;little-coder&lt;/code&gt; + &lt;code&gt;qwen3.5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;writes the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;little-coder&lt;/code&gt; + &lt;code&gt;qwen3.5:latest&lt;/code&gt;, an unregistered id&lt;/td&gt;
&lt;td&gt;writes the file, warning fires&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the control, and it's the one that actually settles the question. It holds the&lt;br&gt;
model constant and only varies whether the id is registered, and the file gets written anyway even&lt;br&gt;
though the warning still fires.&lt;/p&gt;

&lt;p&gt;So the warning was benign, and id resolution wasn't the cause of anything. The original explanation&lt;br&gt;
had inferred causation from adjacency: someone saw a warning next to a failure and wrote down a&lt;br&gt;
mechanism to connect them.&lt;/p&gt;

&lt;p&gt;The retest killed two of its own hypotheses too, which is what separates a control from a&lt;br&gt;
demonstration. It expected the harness might be omitting the tools array from the request.&lt;br&gt;
Refuted, since a different model tool-calls fine through that same adapter. It expected the&lt;br&gt;
unregistered id to disable native tool calling outright. Refuted by the control row itself.&lt;/p&gt;

&lt;p&gt;The correction went back into the record as a marked block, not a silent edit. The wrong claim is&lt;br&gt;
still sitting there, readable, right next to the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correction was a claim too
&lt;/h2&gt;

&lt;p&gt;The retest's own write-up had been careful. It reported exactly what it varied, and said plainly&lt;br&gt;
that it hadn't resolved why the harness's request shape defeats that particular model's parser, and&lt;br&gt;
hadn't chased it any further.&lt;/p&gt;

&lt;p&gt;What travelled outward from it was shorter: not the id, the model.&lt;/p&gt;

&lt;p&gt;That compression is checkable, and it doesn't hold. If the fault were the model itself, the same&lt;br&gt;
model through the same harness would not have scored 8 out of 8 two weeks earlier, writing three&lt;br&gt;
files to disk cleanly. Same box, same model server, same alias, same command-line tool. The one&lt;br&gt;
thing that changed between the run that worked and the run that no-ops is the version bump the&lt;br&gt;
orchestrator performed without asking: 1.9.11 against 1.9.13.&lt;/p&gt;

&lt;p&gt;The retest ran entirely on 1.9.13. It never varied the version, because the version wasn't the axis&lt;br&gt;
it set out to test. It was testing the id-resolution claim, and on that axis its own work holds up&lt;br&gt;
fine. What the evidence actually supports is narrower and less quotable than either slogan: on&lt;br&gt;
1.9.13, this specific model and this specific harness don't work together, while the same harness&lt;br&gt;
works fine with a different model and the same model works fine without that harness. It's an&lt;br&gt;
interaction. The mechanism behind it is still unknown.&lt;/p&gt;

&lt;p&gt;"The harness broke" was too broad a claim. "The model is at fault" replaced it with a different&lt;br&gt;
claim of the same shape, reached the same way: by reading a summary instead of the run underneath&lt;br&gt;
it. The 8-out-of-8 record was sitting the whole time in a benchmarks file the correction never had&lt;br&gt;
a reason to open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two places the correction didn't reach
&lt;/h2&gt;

&lt;p&gt;The correction was folded into four files. The message carrying it listed five places the wrong&lt;br&gt;
claim lived, and one of those was a session log left alone on purpose, because append-only history&lt;br&gt;
shouldn't get rewritten after the fact.&lt;/p&gt;

&lt;p&gt;Nine days later, a check of the record for this piece found the refuted mechanism still&lt;br&gt;
stated as plain fact in two places nobody had listed.&lt;/p&gt;

&lt;p&gt;The trial write-up still said the harness "no longer recognises the model id... fails to attach&lt;br&gt;
the tool-calling scaffold." And the driver's own docstring, the first thing anyone reads before&lt;br&gt;
using it, still explained its own existence as "after a version bump it stops recognising the&lt;br&gt;
model id."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The tool built because of the diagnosis was still carrying the diagnosis, after the diagnosis&lt;br&gt;
itself had been withdrawn.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not because anyone ignored the correction. Because propagating a correction requires knowing every&lt;br&gt;
place the original claim was written, and that list had been made from memory instead of from a&lt;br&gt;
search. Both have since been fixed, the docstring in place, the trial write-up with a dated&lt;br&gt;
correction block that leaves the original wording readable, because a results file is a record of&lt;br&gt;
what was believed at the time, not something to quietly rewrite.&lt;/p&gt;

&lt;p&gt;The real question isn't how two copies were missed. It's why anyone thought four was the complete&lt;br&gt;
set. A claim written into five documents has usually been written into more than five, and the&lt;br&gt;
cheap check, searching for the sentence instead of trying to recall where it went, was sitting&lt;br&gt;
there the whole time, unused.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually carries over
&lt;/h2&gt;

&lt;p&gt;The smallest surface wins twice here, not once. The driver works because it asks the model for&lt;br&gt;
text and writes the file itself, instead of trusting a structured call to survive a parser, a&lt;br&gt;
wrapper, and a version bump all at once. The same instinct applies to how the record of what&lt;br&gt;
happened gets kept. Exit 0 with no artefact is the failure mode worth designing against, which&lt;br&gt;
means checking for the file rather than the status code. Pinning the versions of any tool a&lt;br&gt;
pipeline depends on matters for the same reason, and no agent should be installing anything&lt;br&gt;
globally on its own initiative. A correction is a new claim, not a return to neutral, and it earns&lt;br&gt;
the same scrutiny the original claim never got: a real search for every copy of what it's&lt;br&gt;
replacing, not a list made from memory.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/carried-the-diagnosis/" rel="noopener noreferrer"&gt;thekilted.dev/carried-the-diagnosis&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>localllm</category>
      <category>debugging</category>
      <category>buildinpublic</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Never installable</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sat, 19 Sep 2026 07:51:56 +0000</pubDate>
      <link>https://dev.to/thekilteddev/never-installable-2fh4</link>
      <guid>https://dev.to/thekilteddev/never-installable-2fh4</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/F87SUzqVDc8" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;For three days of real-device testing, the app worked, or seemed to. Every test passed. Every&lt;br&gt;
review came back clean. On a real phone, it had never once been installable, and nothing had ever&lt;br&gt;
said so.&lt;/p&gt;

&lt;p&gt;This is a proof-of-concept, not an NHS product. It was built by one technical person working&lt;br&gt;
alongside an NHS dietetic team. No patient data exists anywhere in it. Nothing leaves the&lt;br&gt;
device unless a lookup runs, and even then it sends nothing but the barcode number itself. Clients&lt;br&gt;
had been sending in photos and rough text descriptions of what they'd eaten, and a dietitian was&lt;br&gt;
manually reading that, finding the matching product, and keying it into Nutritics, the accredited&lt;br&gt;
nutrition-analysis software the team already used. Nutritics has its own patient-facing app, Libro,&lt;br&gt;
that would let clients log the food themselves and skip all of that. Libro just isn't enabled for&lt;br&gt;
this region, and turning it on is weeks away at best, if it happens at all. So the trial runs a&lt;br&gt;
smaller experiment instead: making the manual keying-in as fast and unambiguous as it can be, and&lt;br&gt;
doubling as evidence for whether Libro is worth switching on.&lt;/p&gt;

&lt;p&gt;That's the frame. The build itself comes down to two decisions that held, and a run of confidently&lt;br&gt;
wrong guesses before the real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it never asserts a calorie figure
&lt;/h2&gt;

&lt;p&gt;The first thing tried was the obvious move: point a consumer nutrition app at a barcode and let it&lt;br&gt;
fill in the numbers. Cronometer's first four barcode and photo lookups all came back wrong.&lt;br&gt;
Correcting them by hand was painful enough that the question settled itself. MyFitnessPal has kept&lt;br&gt;
barcode scanning behind a Premium paywall since October 2022, so the one app most clients already&lt;br&gt;
had was already a dead end. Only Open Food Facts, a free, open, no-account database of product&lt;br&gt;
names, gave anything back reliably, and even that only names a product. It doesn't know its&lt;br&gt;
nutrition.&lt;/p&gt;

&lt;p&gt;Reading digits off a barcode is deterministic and checkable, and that was never where Cronometer&lt;br&gt;
failed. It failed at mapping those digits to the right nutrition record through a crowdsourced&lt;br&gt;
database, and that's the exact spot it got wrong four times running. So the tool draws a hard line&lt;br&gt;
at that exact seam. It reads barcodes. It never writes a nutrition figure. Nutritics, with roughly&lt;br&gt;
1.6 million EUROFIR-accredited foods, stays the only source of truth for what anything actually&lt;br&gt;
contains.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the tool never claims a calorie number, it can never be the reason someone has to unpick a&lt;br&gt;
wrong one later.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same discipline runs through barcode validation. A GS1 check digit rejected every single-digit&lt;br&gt;
misread thrown at it in testing, 351 out of 351, which covers the failure mode that actually&lt;br&gt;
happens when a camera misreads a smudged label. But roughly one in ten arbitrary thirteen-digit&lt;br&gt;
numbers will satisfy that check by pure chance, tested against a hundred thousand random numbers,&lt;br&gt;
so "the check digit agrees" only ever means the check digit agrees, not that the barcode is&lt;br&gt;
definitely the right product. An earlier draft of the docs called barcodes "self-verifying." The&lt;br&gt;
project corrected that before shipping, because it was a stronger claim than the tool could&lt;br&gt;
actually back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirty-five foods, not a hundred and twenty
&lt;/h2&gt;

&lt;p&gt;The one insight that shaped everything downstream came from watching how Nutritics itself behaves,&lt;br&gt;
not from the client side at all. Its diet-log workspace works like a spreadsheet, and once a&lt;br&gt;
dietitian has searched for a food and dropped it into one day's log, that same food can be added to&lt;br&gt;
any other day or meal without searching again.&lt;/p&gt;

&lt;p&gt;A week's diary might run to something like a hundred and twenty logged eating occasions. Searched&lt;br&gt;
food by food, that's a hundred and twenty searches. But a person doesn't eat a hundred and twenty&lt;br&gt;
different things in a week. The same handful of items repeat across breakfasts and snacks. Counted&lt;br&gt;
by distinct food instead of by occasion, the same week might only need thirty-five searches. That's&lt;br&gt;
the number the design actually optimises, and it's why the data model splits in two: a Food&lt;br&gt;
Library, where every distinct food gets listed once against a short code, and a Daily Log, where&lt;br&gt;
each eating occasion just references a code and a quantity. A dietitian keying a week's diary isn't&lt;br&gt;
doing a hundred and twenty lookups anymore. They're doing thirty-five, and then a lot of quick&lt;br&gt;
copy-paste.&lt;/p&gt;

&lt;p&gt;It's a small reframe with a real effect on the person actually doing the keying, and it came from&lt;br&gt;
reading how the existing software worked rather than guessing at what a food diary "should" look&lt;br&gt;
like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs only a real phone could find
&lt;/h2&gt;

&lt;p&gt;None of this survived contact with a phone unscathed. The front end shipped as a vanilla-JS&lt;br&gt;
progressive web app, deliberately with no build toolchain (this was meant to be a short-lived trial&lt;br&gt;
workaround, not a product, and a bundler is more to maintain than that deserves). Every change&lt;br&gt;
afterward went through a fresh implementer and an independent reviewer before merging, and that&lt;br&gt;
review step earned its keep almost every round.&lt;/p&gt;

&lt;p&gt;The export path took the worst of it. The first version silently did nothing when someone tapped&lt;br&gt;
"Download" on an Android phone in DuckDuckGo, because a CDN script, &lt;code&gt;xlsx.full.min.js&lt;/code&gt;, that the&lt;br&gt;
service worker hadn't cached could fail to load without throwing anything visible. The fix that followed introduced its own bug:&lt;br&gt;
the downloaded file came back as &lt;code&gt;.bin&lt;/code&gt; instead of &lt;code&gt;.xlsx&lt;/code&gt;, because the fallback download mechanism&lt;br&gt;
wasn't reliably setting the file's MIME type on that browser. Fixed by building the download from&lt;br&gt;
an explicit Blob instead. Then a third bug, sneakier than either: the app would report "Downloaded"&lt;br&gt;
with nothing actually appearing, because the click handler waited on three IndexedDB round-trips&lt;br&gt;
before triggering the download, and that wait was long enough for the phone to quietly expire the&lt;br&gt;
tap's permission to trigger one at all. Nothing threw an error, no dialog appeared, and the app&lt;br&gt;
still reported "Downloaded" as if it had. The fix was to pre-build the export the moment the screen&lt;br&gt;
opened, so the tap had almost nothing left to wait on.&lt;/p&gt;

&lt;p&gt;A fourth export bug turned out not to be this app's to fix. Sharing the file to WhatsApp on Chrome&lt;br&gt;
Android threw a permission error even though the browser's own API had said sharing was possible.&lt;br&gt;
An A/B test settled why: a plain text file shared fine, but the identical share, swapped to the&lt;br&gt;
real xlsx MIME type, failed every time. That's Android Chrome's own share implementation rejecting&lt;br&gt;
the file type at the native layer, and the fix is a fallback, not a workaround: catch that&lt;br&gt;
specific failure and drop back to the working download path.&lt;/p&gt;

&lt;p&gt;Of the four export bugs, all found on a real phone, two gave nothing to go on (silent no-op,&lt;br&gt;
nothing downloaded) and were this app's own to fix. The other two threw a visible error, and only&lt;br&gt;
one of those — Android Chrome refusing the file type — turned out not to be this app's bug at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two files, sixty-eight bytes each
&lt;/h2&gt;

&lt;p&gt;The bug that cost the most time wasn't in any of that. It was that the app was never actually&lt;br&gt;
installable, on any browser, on any host, the entire time.&lt;/p&gt;

&lt;p&gt;The manifest declared full-size icons, but the two actual icon files behind it were 68-byte&lt;br&gt;
placeholder stubs, one pixel by one pixel. Chrome checks that a PWA's declared icons actually exist&lt;br&gt;
at the sizes it claims. When they don't, nothing in the console warns about it, no error ever&lt;br&gt;
fires, and the "Install app" option just never appears. "Add to Home Screen" still worked, but it&lt;br&gt;
could only ever produce a bookmark shortcut, not a real installed app, so offline never worked&lt;br&gt;
either. The service worker, the manifest, and the caching logic underneath all of it had been&lt;br&gt;
correct from the start.&lt;/p&gt;

&lt;p&gt;Before anyone found the icons, two other theories took the blame instead. First the browser: maybe&lt;br&gt;
DuckDuckGo's PWA support was incomplete. Then the host: maybe something about how Namecheap served&lt;br&gt;
the files was the difference from the version that had worked fine on Netlify. Both theories were&lt;br&gt;
plausible. Both were wrong. An earlier comparison between the two hosts had already been written up&lt;br&gt;
as "decisive, host-specific." The actual difference was that Netlify's service-worker cache&lt;br&gt;
had been warm for months and Namecheap's was cold, and that warm cache had quietly been hiding two&lt;br&gt;
unrelated bugs the whole time. What actually surfaced all three bugs at once was a fresh cache forcing every code&lt;br&gt;
path to run for real, on a real phone, for the first time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A wee sixty-eight-byte file doesn't announce itself as the reason anything is broken. It just&lt;br&gt;
sits there, technically present, while every larger theory takes its turn and falls apart first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Light, not orientation
&lt;/h2&gt;

&lt;p&gt;One more misdiagnosis is worth keeping, mostly for how the test caught it. A habit had formed of&lt;br&gt;
always photographing barcodes horizontally, and it looked like it mattered: a vertically held&lt;br&gt;
barcode seemed to fail more often. The obvious explanation was that the scanner's decoder reads&lt;br&gt;
horizontal scan lines and could miss a rotated barcode outright.&lt;/p&gt;

&lt;p&gt;A direct test said otherwise. A barcode held deliberately vertical, in good light, scanned exactly&lt;br&gt;
as well as a horizontal one. Orientation wasn't the variable. Light was, and the horizontal-photo&lt;br&gt;
habit had just happened to correlate with better lighting, not cause anything on its own. The same&lt;br&gt;
test also cleared the gallery-scan refactor, a different piece of code that had carried an&lt;br&gt;
"unverified on real hardware" flag since it was written. Whatever flakiness it had been suspected&lt;br&gt;
of causing turned out to have nothing to do with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;None of this is a finished product, and it isn't meant to be one. The whole tool is a workaround&lt;br&gt;
for not having Libro, and the plan from day one was to retire it without regret the moment Libro&lt;br&gt;
gets switched on for this region, rather than let it grow into something that has to be maintained.&lt;br&gt;
If the manual keying turns out to be genuinely painful even with a well-built sheet in front of the&lt;br&gt;
dietitian, that's the evidence the trial was actually built to produce.&lt;/p&gt;

&lt;p&gt;Either way, what's left isn't a product. It's a barcode reader that only ever claims what it's sure&lt;br&gt;
of, and a debugging log full of things that looked complicated right up until someone found the&lt;br&gt;
actual cause.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/never-installable/" rel="noopener noreferrer"&gt;thekilted.dev/never-installable&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pwa</category>
      <category>debugging</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The benchmark that disproved its own result</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:00:40 +0000</pubDate>
      <link>https://dev.to/thekilteddev/the-benchmark-that-disproved-its-own-result-1kn1</link>
      <guid>https://dev.to/thekilteddev/the-benchmark-that-disproved-its-own-result-1kn1</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/44fVor5Sz1I" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;An article came past claiming a two-year-old coding-trained model beats a newer general model at&lt;br&gt;
editing structured text that isn't code. Its thesis, verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A general chat model treats rules as more of a suggestion because it's optimized to sound&lt;br&gt;
helpful. A coding model treats rules as the whole point.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The article contained no measurements. Not weak measurements. None.&lt;/p&gt;

&lt;p&gt;That's normally enough to close a review. This project turned it into a test instead, because the&lt;br&gt;
claim has a property most of the genre lacks: it's narrow, it names a mechanism, and it's&lt;br&gt;
falsifiable on hardware already sitting here. "Local models are better than you think" isn't&lt;br&gt;
testable. "A coding-trained model follows structural rules more reliably than a general one" is a&lt;br&gt;
Tuesday afternoon's work.&lt;/p&gt;

&lt;p&gt;The test took a few hours. The result was clean, confirmed the article, and was wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Two models already pulled, no new downloads needed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qwen3-coder-30b&lt;/code&gt; — the coding-trained side&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;qwen3:8b&lt;/code&gt; — the general side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general side was chosen after checking its chat template directly through the runtime's own&lt;br&gt;
API: a 1,723-character real ChatML template. That check mattered because another model on this box&lt;br&gt;
ships a template that's just a passthrough, which silently degrades everything sent through it, and&lt;br&gt;
picking that one by accident would have produced a large, fake, extremely publishable result.&lt;/p&gt;

&lt;p&gt;Five fixtures, one per task type: JSON repair, YAML frontmatter, CSV cleanup, Docker log&lt;br&gt;
extraction, markdown tables.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;These models are stochastic, and one run is an anecdote.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So every fixture ran three times, thirty runs in total.&lt;/p&gt;

&lt;p&gt;Every fixture gets an objective gate that is full-equality or parse-and-compare, never substring&lt;br&gt;
matching. That distinction isn't pedantry. A substring check asking "does the output&lt;br&gt;
contain the right value" passes an output that contains the right value and three wrong ones too.&lt;br&gt;
This project has had weak tests pass broken local-model work before, twice, so the checkers are&lt;br&gt;
written to be hostile.&lt;/p&gt;

&lt;p&gt;Two deliberate choices worth naming. &lt;strong&gt;Thinking mode off&lt;/strong&gt;, because on this box's qwen models it&lt;br&gt;
either hides the answer inside a thinking block or gets rejected outright by the server. &lt;strong&gt;Default&lt;br&gt;
temperature&lt;/strong&gt; rather than the low temperature used for code generation, because the claim is about&lt;br&gt;
how these models behave in ordinary use, and tuning the sampling toward precision would have&lt;br&gt;
quietly answered a different question.&lt;/p&gt;
&lt;h2&gt;
  
  
  The grader had a bug, and it found it before any model ran
&lt;/h2&gt;

&lt;p&gt;The checkers are the ground truth in an evaluation like this. If the grader's wrong, every number&lt;br&gt;
downstream is decoration. So &lt;code&gt;structured-text-eval.py&lt;/code&gt; has a &lt;code&gt;--selftest&lt;/code&gt; mode: known-correct answers fed to&lt;br&gt;
each checker, with no model involved, before a single request goes out.&lt;/p&gt;

&lt;p&gt;It failed on the YAML fixture, for a reason that would never have been noticed later.&lt;/p&gt;

&lt;p&gt;The fixture asks a model to add one key to a YAML frontmatter block without disturbing anything&lt;br&gt;
else, and the checker verified the untouched keys still matched, including a date field,&lt;br&gt;
&lt;code&gt;2026-07-01&lt;/code&gt;. YAML's parser doesn't return that as a string. It returns a &lt;code&gt;datetime.date&lt;/code&gt; object,&lt;br&gt;
because unquoted ISO dates are a date type in YAML. Comparing that object to the string&lt;br&gt;
&lt;code&gt;"2026-07-01"&lt;/code&gt; is always false.&lt;/p&gt;

&lt;p&gt;The checker would have failed a perfectly correct answer, on every trial, for both models. The fix&lt;br&gt;
is the &lt;code&gt;str()&lt;/code&gt; in the line that guards those untouched keys, still sitting there today:&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;if&lt;/span&gt; &lt;span class="n"&gt;fm&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;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Weekly Report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fm&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;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; \
        &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;fm&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;tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;ops&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;weekly&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;What matters is when it was caught: before the run, by a check written specifically to distrust the&lt;br&gt;
grading logic, rather than after, when a mysterious YAML failure would have needed explaining and&lt;br&gt;
might have been written up as a finding instead.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The grading logic needs the same adversarial discipline as the thing it grades.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's code, and nobody reviews it, because it lives in the part of the experiment everyone treats as&lt;br&gt;
infrastructure.&lt;/p&gt;

&lt;p&gt;We have written about &lt;a href="https://thekilted.dev/checks-that-cannot-fail/" rel="noopener noreferrer"&gt;checks like that&lt;/a&gt; before. That check could only&lt;br&gt;
ever come back green, whatever it was pointed at. This one had the same defect running the other&lt;br&gt;
way, and would only ever have come back red.&lt;/p&gt;
&lt;h2&gt;
  
  
  The first result confirmed the article
&lt;/h2&gt;

&lt;p&gt;Thirty runs, and the coding model came out ahead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fixture&lt;/th&gt;
&lt;th&gt;qwen3-coder-30b&lt;/th&gt;
&lt;th&gt;qwen3:8b&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON repair&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML frontmatter&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSV cleanup&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker log extraction&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markdown tables&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Every fixture, first run, three trials each (a mark stands for all three — every fixture's trials agreed). Four fixtures agree completely. One doesn't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That fixture was extracting every ERROR line from a Docker log into structured JSON. The coding&lt;br&gt;
model scored 3 out of 3 there, the general model 0 out of 3.&lt;/p&gt;

&lt;p&gt;Look at the shape of that. It isn't noisy. It isn't one unlucky trial. One model succeeded every&lt;br&gt;
time and the other failed every time, on the fixture most about following a structural rule, which&lt;br&gt;
is exactly the mechanism the article named. A clean, consistent, mechanism-matching split, in the&lt;br&gt;
predicted direction.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's what a real finding looks like. It's also what this one looked like, and it was a bug in&lt;br&gt;
the fixture itself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  What the raw output actually said
&lt;/h2&gt;

&lt;p&gt;The harness records every output verbatim rather than just pass or fail, so the failures were&lt;br&gt;
readable, not just countable.&lt;/p&gt;

&lt;p&gt;The general model hadn't mangled the JSON. It hadn't missed lines, invented fields, or drifted&lt;br&gt;
from the schema. Across all three trials it had produced valid, well-formed, correctly structured&lt;br&gt;
output, and put the log-level word inside the message field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"message": "ERROR Failed to connect to cache: timeout after 5s"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coding model had stripped it. The expected answer stripped it. So the checker failed the&lt;br&gt;
general model, correctly, against a specification the prompt never actually stated.&lt;/p&gt;

&lt;p&gt;The prompt said to extract each ERROR line into &lt;code&gt;{timestamp, service, message}&lt;/code&gt;. It never said&lt;br&gt;
whether the word "ERROR" belongs in the message. One model read it one way, the other read it the&lt;br&gt;
other way, and both readings are defensible: the line begins with the level, and whether that&lt;br&gt;
level is part of the message or metadata about it is a genuine ambiguity, not a comprehension&lt;br&gt;
failure.&lt;/p&gt;

&lt;p&gt;Reword the prompt, adding one clause: "the message field must contain ONLY the text after the&lt;br&gt;
ERROR level word, not the word ERROR itself." Rerun all thirty.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fixture&lt;/th&gt;
&lt;th&gt;qwen3-coder-30b&lt;/th&gt;
&lt;th&gt;qwen3:8b&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON repair&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YAML frontmatter&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSV cleanup&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker log extraction&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markdown tables&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;The same grid, once the prompt said which part of the line the message excludes. The one cell that failed is the only one that changed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The gap didn't shrink. It vanished.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The result that ended up under scrutiny was the one that confirmed the hypothesis.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's backwards from how it usually goes. A disconfirming result gets picked apart, because it's&lt;br&gt;
annoying and someone wants it to be a bug. A confirming result gets written up, because it agrees&lt;br&gt;
with what you already believed and there's nothing left to explain.&lt;/p&gt;

&lt;p&gt;Here, the confirming result was the buggy one. The rigour that caught it wasn't scepticism about&lt;br&gt;
the article. Its claim was taken seriously enough to test properly. It was a checklist habit: read&lt;br&gt;
the raw output behind a pass rate before reporting it, regardless of which way the result points.&lt;br&gt;
Without that habit, the write-up would have been "tested it, and the article is right," with a&lt;br&gt;
clean table underneath and no reason for anyone to look again.&lt;/p&gt;

&lt;p&gt;The null result is also weaker than it sounds, worth stating plainly. It doesn't show the&lt;br&gt;
article is wrong. The article's actual comparison was a 3B coding model against a much larger&lt;br&gt;
general one, a full generation apart and roughly three times the size difference. Both models&lt;br&gt;
tested here are modern and well above the size where basic structure-following stops being in&lt;br&gt;
question. The claim may be entirely real at the article's scale and simply invisible at this one.&lt;br&gt;
That wasn't tested, because the relevant general model doesn't fit this box's memory budget. So&lt;br&gt;
the finding is narrow: on these two models, on these five task types, with unambiguous&lt;br&gt;
instructions, there's no difference. Nothing about standing practice changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing that did not survive
&lt;/h2&gt;

&lt;p&gt;Preparing this write-up meant going back to the raw output file. The committed file holds thirty&lt;br&gt;
results, all passing. The run that produced the twelve-out-of-fifteen split, the one this entire&lt;br&gt;
piece is about, no longer exists: the corrected fixtures were rerun into the same output path and&lt;br&gt;
overwrote it.&lt;/p&gt;

&lt;p&gt;The specific output quoted above survives because it was copied into the results log the same&lt;br&gt;
day, by the session that had it on screen. That's a good contemporaneous record. It isn't the&lt;br&gt;
artefact.&lt;/p&gt;

&lt;p&gt;Worth noticing which run it was. What didn't survive was the disconfirming evidence: the thing&lt;br&gt;
that was wrong, the run that failed, the state before the correction. Nobody deleted&lt;br&gt;
anything on purpose. The rerun simply wrote to the same filename, because the interesting output&lt;br&gt;
was assumed to be the final one.&lt;/p&gt;

&lt;p&gt;The final output is the one you'll never need. It's the one that agrees with the conclusion you&lt;br&gt;
already wrote down. The run worth keeping is the one that made you look twice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/the-benchmark-that-disproved-itself/" rel="noopener noreferrer"&gt;thekilted.dev/the-benchmark-that-disproved-itself&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>localmodels</category>
      <category>llm</category>
      <category>evaluation</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The file nobody reread</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:00:47 +0000</pubDate>
      <link>https://dev.to/thekilteddev/the-file-nobody-reread-5d5</link>
      <guid>https://dev.to/thekilteddev/the-file-nobody-reread-5d5</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/L_jSpfwmQ7s" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;Field notes from a hosting migration for an NHS food-diary proof of concept. A certificate checker&lt;br&gt;
was quietly lying about whether a fix had worked, and two sessions rejected the same hosting idea&lt;br&gt;
twice, four days apart, without either reading the other's reasoning. The move itself was still on&lt;br&gt;
hold when this was written.&lt;/p&gt;

&lt;p&gt;This is a proof-of-concept, not an NHS product, built by one technical person working alongside an&lt;br&gt;
NHS dietetic team. No patient data exists anywhere in it. The app stores everything on the&lt;br&gt;
client device and nothing else. The trigger for all of this was mundane: Netlify's free tier caps&lt;br&gt;
a project at 15 credits per production deploy, 300 a month, which works out to roughly twenty&lt;br&gt;
deploys before the meter runs dry. The project was already rationing pushes against that ceiling,&lt;br&gt;
and shared hosting on Namecheap, already being set up for another project the same person runs,&lt;br&gt;
looked like a way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rejections, four days apart
&lt;/h2&gt;

&lt;p&gt;GitHub Pages came up early, and the project turned it down fast. On the free plan it only serves&lt;br&gt;
public repositories. A private repo needs a paid GitHub plan, and even then the site still&lt;br&gt;
publishes to a public URL with no login wall short of Enterprise. Netlify's free tier deploys from&lt;br&gt;
a private repo to an unlisted one instead, which fit the stated preference to keep the code itself&lt;br&gt;
private. No patient data was ever going to sit in that repo either way. The preference was about&lt;br&gt;
the source, not about anything a patient had typed.&lt;/p&gt;

&lt;p&gt;Four days later, GitHub Pages came up again. Not from a different person with a different view. A&lt;br&gt;
coding session working on the project proposed the exact idea an earlier session had already&lt;br&gt;
rejected, because nobody had reread the decisions file before speaking. The occasion was almost&lt;br&gt;
incidental: a different, newer domain had surfaced, already live on GitHub Pages with a working&lt;br&gt;
certificate, and for a moment it looked like an obvious destination. That session looked into it,&lt;br&gt;
then declined it again, for the same reason as the first time. That reason had not moved. It had&lt;br&gt;
simply never been reread. This round did turn up one genuinely new blocker: the food-diary repo&lt;br&gt;
and the newer domain lived under two different GitHub accounts, and GitHub blocks a custom domain&lt;br&gt;
from being claimed by a second account once it's verified on the first, specifically to stop that&lt;br&gt;
kind of takeover. A real finding, arrived at by accident, in the course of re-litigating a question&lt;br&gt;
that had already been settled.&lt;/p&gt;

&lt;p&gt;A decisions log nobody rereads is decoration. It was there, it had the right answer sitting in it,&lt;br&gt;
and a session still asked the question again anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  A certificate checker that had been lying
&lt;/h2&gt;

&lt;p&gt;The SSL side had its own real bug and its own fake one. The real bug: the domain's nameservers&lt;br&gt;
were pointed at Namecheap's basic DNS, which only handles URL forwarding, not actual hosting.&lt;br&gt;
Switched to the proper hosting DNS and that half resolved. An earlier assumption, picked up from a&lt;br&gt;
web search during planning, claimed the hosting tier included self-service AutoSSL. It didn't.&lt;br&gt;
Triggering AutoSSL turned out to require WHM-level (the hosting provider's own backend) server-administrator access, which an ordinary&lt;br&gt;
shared-hosting account never gets. That was confirmed against Namecheap's own documentation once&lt;br&gt;
the free "Run AutoSSL" button turned out not to exist anywhere in the dashboard. The free certificate that does ship&lt;br&gt;
with the hosting plan has to be issued from Namecheap's own side, and nothing on the customer's end&lt;br&gt;
can force it.&lt;/p&gt;

&lt;p&gt;The fake bug was worse, because it looked like a real one for a while. This laptop's own antivirus&lt;br&gt;
intercepts TLS connections and reissues every certificate itself, so anything running on the&lt;br&gt;
machine, &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;openssl&lt;/code&gt; included, was reporting the antivirus's own certificate instead&lt;br&gt;
of the real one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The tool being used to answer "has the SSL fix landed yet" could not tell the truth about it even&lt;br&gt;
in principle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Checking from a phone worked around it, and later a tool that runs the request server-side,&lt;br&gt;
outside the interception entirely, confirmed it for good. Two support agents tried&lt;br&gt;
force-validating the certificate from Namecheap's own side with no result, and a follow-up ticket&lt;br&gt;
went in to track it as a pure backend wait. Nothing on the customer side could shortcut that part.&lt;br&gt;
The certificate issued not long after, and a server-side check confirmed clean HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things the plan hadn't accounted for
&lt;/h2&gt;

&lt;p&gt;First: SSH turned out to be off by default on shared hosting, though the migration plan had been&lt;br&gt;
written without confirming that. The fix was self-service, a control panel option called "Manage&lt;br&gt;
Shell," which gives no hint it's SSH, listening on a non-standard port rather than the usual one. Second:&lt;br&gt;
pushing to the new remote from an automated shell hung indefinitely with no error, because a global&lt;br&gt;
credential setting that keeps two separate GitHub accounts from colliding also blocks the plain&lt;br&gt;
per-repo push path unless a repo-level override is set. That global setting has to stay exactly as&lt;br&gt;
it is. The only new part is the missing per-repo override.&lt;/p&gt;

&lt;p&gt;A smaller trap surfaced once both hosts were finally level with GitHub again: the control panel's&lt;br&gt;
Git tool has an "Update" button that only saves configuration. It doesn't fetch, pull, or deploy&lt;br&gt;
anything, and those actions live on a separate tab entirely. For a repository that was deliberately&lt;br&gt;
cloned through the control panel's own "Clone a Repository" tool, with a specific option left unchecked to&lt;br&gt;
preserve its automatic deploy hook, even that tab's own "Update from Remote"&lt;br&gt;
option turns out to be a no-op. It's a push target, not a pull source. Reading the UI correctly&lt;br&gt;
took longer than reading the DNS panel had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deleting a task is not a local edit
&lt;/h2&gt;

&lt;p&gt;One step in the migration plan retired Netlify outright once the new host was live. Once Namecheap&lt;br&gt;
turned out to be an interim stop rather than the final destination, the task was dropped, which&lt;br&gt;
seemed like a small, contained change. It wasn't. That same task had been referenced as a&lt;br&gt;
precondition in five other places across the plan, and one of those was dangerous on its own: a&lt;br&gt;
later step told a future session to delete the Netlify credit-cap constraint from the project's own&lt;br&gt;
guide file, once hosting had moved. Hosting hadn't moved. A session following that instruction&lt;br&gt;
literally would have silently removed a still-live limit from the record that exists to track it.&lt;br&gt;
All five references were found and corrected before that could happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed the private-repo rule
&lt;/h2&gt;

&lt;p&gt;The preference that had driven both GitHub Pages rejections wasn't overturned by a technical&lt;br&gt;
argument. It was relaxed on purpose, once the user actually named a real gap instead of waving it&lt;br&gt;
off: the diary's free-text fields, a description typed next to a photo, have no structural way to&lt;br&gt;
stop someone entering something identifying. Going public meant accepting that gap rather than&lt;br&gt;
pretending it away. In the user's own words, it was "hard to guard... loose enough to say go&lt;br&gt;
public." The account-split blocker from the second rejection turned out not to matter here either,&lt;br&gt;
since the plan kept the repo under its existing account rather than moving it to the newer brand's.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bigger swap, investigated instead of just refused
&lt;/h2&gt;

&lt;p&gt;A smaller question, whether a branded subdomain could dodge a shared hosting username, led&lt;br&gt;
somewhere larger: making the newer, higher-traffic domain the account's designated "Main Domain,"&lt;br&gt;
on the assumption that the label itself would grant it more subdomain headroom. That assumption&lt;br&gt;
didn't survive a check. The account's subdomain quota is shared regardless of which domain holds&lt;br&gt;
that label, so an ordinary addon domain gets exactly the same headroom. What an&lt;br&gt;
actual swap would require turned out to be much bigger than the label suggested: a different&lt;br&gt;
project's own blog would have to leave its free, automatic hosting for manual shared-hosting&lt;br&gt;
deploys with none of that automation and the same multi-day certificate process already documented&lt;br&gt;
here. That's not a call this project gets to make about another one. It was declined without&lt;br&gt;
touching anything, and the idea went out as two information-only notes to the two other projects'&lt;br&gt;
own owners, for them to decide on their own terms.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it actually landed
&lt;/h2&gt;

&lt;p&gt;The reversal, when it came, was decided outside any of these sessions, by the one person who owns&lt;br&gt;
all three projects at once: Netlify was retired entirely, and the food diary moved to GitHub Pages&lt;br&gt;
after all, kept under its existing account rather than a newer pseudonymous one. That keeps a tool&lt;br&gt;
built under a real identity separate from a brand built under a pseudonym, and it sidesteps the&lt;br&gt;
cross-account domain restriction outright rather than working around it.&lt;/p&gt;

&lt;p&gt;The account question that had paused the move turned out simpler than the plan in flight at the&lt;br&gt;
time suggested. Rather than standing up a new account, the existing one kept its history and its&lt;br&gt;
repos exactly where they were and simply changed its own display name. Same account throughout,&lt;br&gt;
just renamed partway through. The migration is complete now. Both old hosts are fully retired, and&lt;br&gt;
nothing is left dual-hosted.&lt;/p&gt;

&lt;p&gt;It ended without a clever fix. Two things went wrong here that weren't code. Both were caught the&lt;br&gt;
same way, by someone who actually checked instead of assuming. A certificate check that had every&lt;br&gt;
reason to look trustworthy was quietly answering a different question than the one being asked. And&lt;br&gt;
a written decision only held once someone actually reopened the file before speaking a second time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/nobody-reread/" rel="noopener noreferrer"&gt;thekilted.dev/nobody-reread&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hosting</category>
      <category>devops</category>
      <category>buildinpublic</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The verdict that survived its own correction</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sun, 30 Aug 2026 06:15:27 +0000</pubDate>
      <link>https://dev.to/thekilteddev/the-verdict-that-survived-its-own-correction-50gd</link>
      <guid>https://dev.to/thekilteddev/the-verdict-that-survived-its-own-correction-50gd</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/A2RhXAwQORo" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;This project started with a conclusion already sitting in its lap. In June, an earlier stretch of&lt;br&gt;
work on the same machine had tested whether a local model could handle iterative coding: writing&lt;br&gt;
files, running tests, fixing what broke, without a human shuttling text between a chat window and&lt;br&gt;
an editor. The verdict was no. Not viable. That was the finding the whole project was built on.&lt;/p&gt;

&lt;p&gt;On 4 July, someone re-tested it. It fell over in about two minutes.&lt;/p&gt;

&lt;p&gt;The interesting part isn't that the verdict was wrong. It's that the verdict was right. It had&lt;br&gt;
been stated precisely, with its own explanation attached, and none of that survived the trip into&lt;br&gt;
the new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The re-test
&lt;/h2&gt;

&lt;p&gt;The task was a word search game: three files, a 10x10 grid, eight words hidden horizontally,&lt;br&gt;
vertically or diagonally, click-and-drag to select, correct words struck through in the list, a&lt;br&gt;
New Game button. Eight scored criteria. The eighth is the one that matters: ask for one follow-up&lt;br&gt;
fix (make found words green) and check it lands without breaking the drag highlighting that&lt;br&gt;
already worked. That regression check is what had killed every previous run.&lt;/p&gt;

&lt;p&gt;Run through &lt;code&gt;little-coder&lt;/code&gt;, a small harness that gives the model real write and read tools instead&lt;br&gt;
of asking it to print code into a chat window, against &lt;code&gt;qwen3-coder-30b&lt;/code&gt; served by Ollama, the&lt;br&gt;
result was 8 out of 8 in 2.1 minutes. The follow-up fix took another 30 seconds. All of it verified&lt;br&gt;
in a real browser with simulated drag events, not taken from the model's own account of what it had&lt;br&gt;
done.&lt;/p&gt;

&lt;p&gt;The same benchmark against the smaller &lt;code&gt;qwen3.5-9b&lt;/code&gt; scored 7.5 out of 8 in 44.1 minutes. It lost&lt;br&gt;
half a point on reverse-placed and diagonal words: no reversed-string check, an off-by-one in the&lt;br&gt;
highlight loop. It still passed the regression test, the first local setup here to manage that.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;qwen3-coder-30b&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;td&gt;2.1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3.5-9b&lt;/td&gt;
&lt;td&gt;7.5/8&lt;/td&gt;
&lt;td&gt;44.1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The quality gap was only half a point; the time gap was a full 42 minutes.&lt;/p&gt;

&lt;p&gt;Two honest marks sit against the 8 out of 8. One transient failure died with "Stream ended without&lt;br&gt;
finish_reason" and an identical retry worked. On criterion 8, one green pixel was never confirmed,&lt;br&gt;
because the preview tab had collapsed to a zero-width viewport and inline styles had stopped&lt;br&gt;
applying: impossible in a real browser, so it was scored as a harness fault rather than a code&lt;br&gt;
fault. That was a judgment call, made and written down as one at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this project wrote down that evening
&lt;/h2&gt;

&lt;p&gt;The June verdict was a harness problem, not a model problem.&lt;/p&gt;

&lt;p&gt;It's a clean line. It reads as an insight. It went into the project's working memory as a headline&lt;br&gt;
finding, was quoted straight into the active constraints file, and shaped everything scoped&lt;br&gt;
afterward: if the harness is the variable that matters, you invest in harnesses and stop shopping&lt;br&gt;
for models.&lt;/p&gt;

&lt;p&gt;The investment was right. The claim wasn't, and nobody checked it for two weeks, because a&lt;br&gt;
conclusion that's useful and turning out well doesn't feel like it needs auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the original verdict actually said
&lt;/h2&gt;

&lt;p&gt;The June work had been handed over as a document. Reading it properly, two weeks late, the verdict&lt;br&gt;
is one row in a table of six use cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Locally viable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code CLI backend&lt;/td&gt;
&lt;td&gt;✕ broke — models misinterpret Claude Code's own system prompt format, not a config problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-file document extraction (LM Studio + Gemma 4 E4B, 8192 ctx)&lt;/td&gt;
&lt;td&gt;✓ met — works well, the best multi-file result of anything tried&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single-file extraction, agent + filesystem (Goose + Ollama, qwen3.5-9b)&lt;/td&gt;
&lt;td&gt;✓ met — works, but slow: 62 seconds per file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iterative coding (any local chat UI)&lt;/td&gt;
&lt;td&gt;✕ broke — not viable, and structural: five failure modes, not a fixable bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single-turn code generation (Goose or LM Studio, max context)&lt;/td&gt;
&lt;td&gt;✓ met — viable as a baseline handoff into a human editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quota fallback for real sessions (fcc-server)&lt;/td&gt;
&lt;td&gt;✕ broke — routed to Gemini instead, local wasn't trusted as the fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;June's headline verdicts, all six, from the same document that scoped iterative coding to "any&lt;br&gt;
local chat UI." The row that got re-tested is one of six, not the whole page.&lt;/p&gt;

&lt;p&gt;The scope is in the verdict line. Not "local models cannot do iterative coding." Local models&lt;br&gt;
cannot do iterative coding in a chat UI, which is a claim about an interface.&lt;/p&gt;

&lt;p&gt;Below it, five structural failure modes are listed. Stateless regression, where a fix in turn N&lt;br&gt;
gets silently undone in turn N+1: observed, in that same word search task, as a drag highlighter&lt;br&gt;
fixed and then broken. Overconfident reporting. Silent context truncation. Effective context not&lt;br&gt;
matching advertised. No cross-session persistence.&lt;/p&gt;

&lt;p&gt;And then, closing that section:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why Claude Code avoids 1–2: tool-mediated verification per step, persistent conversation&lt;br&gt;
context — architecture, not raw model capability.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;June had already worked out that the constraint was architectural. It's written down, in the&lt;br&gt;
document that was handed over, in bold, five weeks before the July re-test that "discovered" it.&lt;/p&gt;

&lt;p&gt;July didn't overturn June. July supplied the harness June had described, and measured what&lt;br&gt;
happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where June was too optimistic
&lt;/h2&gt;

&lt;p&gt;Of the five failure modes, June expected a harness to handle the first two: stateless regression&lt;br&gt;
and overconfident reporting, both covered by tool-mediated verification and persistent context.&lt;/p&gt;

&lt;p&gt;The first half held. The second didn't.&lt;/p&gt;

&lt;p&gt;Overconfident reporting is a model claiming completion when a feature doesn't actually work, and a&lt;br&gt;
harness only catches that if its tests are adversarial. This project has watched a weak test&lt;br&gt;
launder a false claim into a green, twice. A redaction check passed with&lt;br&gt;
&lt;code&gt;assert "84210.55" not in out&lt;/code&gt; while a partially redacted amount, &lt;code&gt;842[AMOUNT]&lt;/code&gt;, leaked straight&lt;br&gt;
through it. A loader passed nineteen of nineteen with a leaked loop variable, masked by a fixture&lt;br&gt;
that only ever exercised one file.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Real file tools, persistent context, tests that ran green: both greens were wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So June wasn't simply more careful than its own summary. On this one point it was optimistic, and&lt;br&gt;
the correction only turned up once this project built the harness June had described and ran it&lt;br&gt;
long enough to get burned.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually went wrong
&lt;/h2&gt;

&lt;p&gt;Four things actually changed between June and July, worth stating because it means the re-test was&lt;br&gt;
never a controlled experiment. The model changed. The harness changed. The runtime flags changed&lt;br&gt;
(July ran with flash attention and a quantised key-value cache, June predated both). And the prompt&lt;br&gt;
changed, because June's prompt text was never preserved and had to be rebuilt from scratch.&lt;/p&gt;

&lt;p&gt;That last one isn't carelessness. The June raw evaluation file survives, and it's 809 bytes: a&lt;br&gt;
findings summary whose entire task description is one line. There was no prompt left to preserve by&lt;br&gt;
the time anyone went looking. Reconstruction was the only option on the table.&lt;/p&gt;

&lt;p&gt;But the four variables are a side issue. The real failure was compression.&lt;/p&gt;

&lt;p&gt;What travelled from June into this project was the phrase "iterative coding not viable." The scope,&lt;br&gt;
any local chat UI, didn't make the trip. Neither did the mechanism: architecture rather than raw&lt;br&gt;
model capability.&lt;/p&gt;

&lt;p&gt;Then someone re-tested the compressed form, found it wanting, and corrected it into a claim broader&lt;br&gt;
and less accurate than the original. A precise, well-reasoned, correctly scoped verdict went in one&lt;br&gt;
end and came out the other as a slogan, and the slogan is what the project ended up arguing with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The verdict, June:&lt;/strong&gt; Iterative coding — not viable (structural, see the five failure modes) — any&lt;br&gt;
local chat UI. Scoped to an interface, with its own mechanism named: architecture, not raw model&lt;br&gt;
capability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What travelled:&lt;/strong&gt; "Iterative coding not viable." The scope and the mechanism both stayed behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What got re-tested:&lt;/strong&gt; "Harness, not model." Broader and less accurate than the verdict it&lt;br&gt;
replaced, and the claim this project ended up arguing with.&lt;/p&gt;

&lt;p&gt;The handover document's own header says it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nothing here needs re-deriving — it was all tested hands-on.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This project re-derived it anyway, and ended up with a worse version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that only appeared while writing this
&lt;/h2&gt;

&lt;p&gt;Writing this piece meant going back to the June sources, and the first attempt to find them failed.&lt;br&gt;
The active constraints file listed the handover document under a path in the other project, the one&lt;br&gt;
that had written it. It wasn't there. Searching that project turned up nothing.&lt;/p&gt;

&lt;p&gt;The conclusion drawn from that was that both June primaries had been deleted. That conclusion went&lt;br&gt;
into a correction to this project's own files, into an early draft of this piece, and into a&lt;br&gt;
request asking another project to recover them from git history.&lt;/p&gt;

&lt;p&gt;The document was sitting in this project's own repository root. It had been there since the first&lt;br&gt;
commit. It had been written elsewhere and moved across at handover, exactly as its own header says.&lt;br&gt;
The raw evaluation file had moved into a shared knowledge vault during a migration weeks earlier,&lt;br&gt;
and was just as findable. The other project located both files in minutes, and mentioned, almost as&lt;br&gt;
an aside, that it has no git history at all, so the recovery being asked for could never have worked&lt;br&gt;
anyway.&lt;/p&gt;

&lt;p&gt;One failed lookup at one stale path became "gone from disk," and a fair amount was built on top of&lt;br&gt;
that before anyone checked the obvious place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's the same mistake this piece is about, made while writing about it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An absence, asserted from a single negative result. A conclusion, drawn from a summary (the path in&lt;br&gt;
the constraints file) rather than from the thing itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;The finding was rewritten to what the evidence actually carries. The harness thesis is June's, not&lt;br&gt;
this project's, and it was right. What this project added is confirmation and a measurement. The&lt;br&gt;
broad version, "harness, not model," is now marked as over-stated, because it took this project's&lt;br&gt;
own later evidence, not June's, to find the one place a harness alone wasn't enough.&lt;/p&gt;

&lt;p&gt;The practical guidance didn't change at all, which is the uncomfortable part. Invest in the&lt;br&gt;
harness. Verify with real tool output. Keep the local tier for bounded work. All still correct, all&lt;br&gt;
still what this project does. A claim can be over-stated and still point the right way, and that&lt;br&gt;
combination is exactly what stops anyone from checking it again.&lt;/p&gt;

&lt;p&gt;Two rules came out of this, both narrow and mechanical. Before re-testing an inherited verdict,&lt;br&gt;
read the primary document, not your own summary of it: an active-constraints file strips scope and&lt;br&gt;
exceptions by design, because stripping detail is what it's for. And an absence needs more than one&lt;br&gt;
failed lookup, especially when the path you searched came from the same compressed source you're&lt;br&gt;
already re-deriving from.&lt;/p&gt;

&lt;p&gt;What travels between sessions, projects and people is the conclusion. The conditions that produced&lt;br&gt;
it stay behind. A conclusion without its conditions is a rumour with a number attached.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/the-verdict-that-survived-its-own-correction/" rel="noopener noreferrer"&gt;thekilted.dev/the-verdict-that-survived-its-own-correction&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>localmodels</category>
      <category>llm</category>
      <category>softwareengineering</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Protected nothing</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sun, 23 Aug 2026 13:52:56 +0000</pubDate>
      <link>https://dev.to/thekilteddev/protected-nothing-2fgc</link>
      <guid>https://dev.to/thekilteddev/protected-nothing-2fgc</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/r5SC-Dyw2o4"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;Field notes from a proof-of-concept food-diary tool built alongside one NHS dietetic team,&lt;br&gt;
not an official NHS product, no patient data anywhere in it. A privacy guardrail blocked its&lt;br&gt;
repository from going public. Traced through the code, it protected nothing that going public would&lt;br&gt;
have exposed.&lt;/p&gt;

&lt;p&gt;A rule sounded exactly right, protected exactly nothing, and stood as the one thing blocking real&lt;br&gt;
work, until someone traced it back to what the code actually does.&lt;/p&gt;

&lt;p&gt;The rule was simple. Before the food diary's repository could go public, it needed a guardrail&lt;br&gt;
against identifiable data leaking through the diary's own free-text fields, chiefly a description a&lt;br&gt;
client might type next to a reference photo. Someone could type their own name into it without&lt;br&gt;
thinking, to help remember whose entry it was. That risk was real, agreed on at the time in the&lt;br&gt;
project's own words as "hard to guard... loose enough to say go public," and it sat there as the one&lt;br&gt;
condition standing between the repository and a public GitHub Pages migration everything else was&lt;br&gt;
ready for.&lt;/p&gt;

&lt;p&gt;The condition held for a day. Then, traced through the actual code rather than argued about again,&lt;br&gt;
it didn't survive the first look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the description actually goes
&lt;/h2&gt;

&lt;p&gt;The description a client types is captured in the app, stored as a note on a log entry in IndexedDB&lt;br&gt;
on the device, and exported into a spreadsheet the dietitian handles. It is never committed, never&lt;br&gt;
pushed, never served. The repository's visibility has no effect on it whatsoever, because it was&lt;br&gt;
never in the repository to begin with. A guardrail on that field would have protected data that&lt;br&gt;
going public put at no risk at all.&lt;/p&gt;

&lt;p&gt;A second, independent reason cut just as deep. The app ships with no build step at all, just&lt;br&gt;
straight unminified JavaScript, so its entire source has been downloaded verbatim by every single&lt;br&gt;
visitor to the live site since the day it launched. Repo visibility was never protecting the&lt;br&gt;
application code either, because the browser was already handing that code to anyone who asked. The&lt;br&gt;
"code stays private" preference behind two earlier hosting rejections had, for the part of the&lt;br&gt;
project a visitor could actually reach, never once been true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The place nobody checked
&lt;/h2&gt;

&lt;p&gt;If the free-text field was never the risk, going public should have been safe from day one. It&lt;br&gt;
wasn't, quite, and the reason was somewhere else entirely: roughly forty tracked files sitting&lt;br&gt;
outside the application folder, never served to a visitor and shielded from view by nothing but the&lt;br&gt;
repository itself being private, among them migration notes, deploy docs, session logs, and a stray&lt;br&gt;
config file. Grepping through all of it for anything infrastructure-shaped turned up exactly one&lt;br&gt;
genuinely sensitive string: a hosting-account username, and not even this project's own account. It&lt;br&gt;
belonged to someone else's.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;In the repo&lt;/th&gt;
&lt;th&gt;Repo privacy hides it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free-text descriptions&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application source&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~40 files outside the app folder&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things the repository question could have been about. Only the last is both tracked in the&lt;br&gt;
repository and hidden by nothing else, so it is the only one that going public actually exposes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The thing everyone had been scared of couldn't actually happen. The thing worth being scared of&lt;br&gt;
had been sitting in a folder nobody had opened, the whole time the guardrail argument was running.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A fix that doesn't depend on remembering
&lt;/h2&gt;

&lt;p&gt;The fix that shipped doesn't ask anyone to remember anything. Rather than scrub the sensitive files&lt;br&gt;
out of a single shared repository and trust every future session to keep scrubbing, the project&lt;br&gt;
split in two: one private repository holding everything, full history intact, and one public&lt;br&gt;
repository synced from it that holds only the application directory's own contents. Nothing outside&lt;br&gt;
&lt;code&gt;app/&lt;/code&gt; can leak, because nothing outside &lt;code&gt;app/&lt;/code&gt; is ever in the public repo to begin with.&lt;/p&gt;

&lt;p&gt;Purging the sensitive files from one shared repository's history was considered and turned down on&lt;br&gt;
purpose. &lt;code&gt;NOTES.md&lt;/code&gt; gets written every session, which means every future session is a fresh chance&lt;br&gt;
to type an identifier back into a file that's now public, and a miss like that is silent and&lt;br&gt;
unrecoverable the moment the repository is cloned. The split doesn't have that failure mode. There's&lt;br&gt;
nothing to miss, because the sensitive files are never in the repo that goes public in the first&lt;br&gt;
place.&lt;/p&gt;

&lt;p&gt;One faster fix was on the table too, and turned down for a completely different kind of reason.&lt;br&gt;
GitHub offers private-repo Pages hosting on its paid tier, for around four dollars a month, which&lt;br&gt;
would have made the whole question go away without touching a single file. Food diary's own author&lt;br&gt;
turned it down outright, on the spot, with two words: "not pro." No technical argument followed it,&lt;br&gt;
because none was needed. It was a spending decision, not an engineering one, and the project didn't&lt;br&gt;
pretend otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the guardrail actually protects now
&lt;/h2&gt;

&lt;p&gt;The free-text field is still a real concern. It existed before this repository was ever considered&lt;br&gt;
for public release, and it will keep existing after, because it's a property of what the app lets&lt;br&gt;
someone type, not of who can see the code. It just isn't a hosting decision, and treating it as one&lt;br&gt;
is the mistake this piece is actually about. It has its own design question to answer, on its own&lt;br&gt;
schedule, and the fix for it won't be a line in a hosting migration plan.&lt;/p&gt;

&lt;p&gt;The pattern generalises past one repository. The project specified a control against a threat that&lt;br&gt;
sounded exactly right and had never been traced to the actual place the data flows through. It still&lt;br&gt;
held up real work, however briefly. And the thing worth guarding against the whole time was sitting&lt;br&gt;
in plain sight, in a directory nobody had thought to check, because the argument had already used up&lt;br&gt;
everyone's attention on the door that was never actually open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/protected-nothing/" rel="noopener noreferrer"&gt;thekilted.dev/protected-nothing&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>privacy</category>
      <category>buildinpublic</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Invoked, not executed</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Thu, 20 Aug 2026 07:35:56 +0000</pubDate>
      <link>https://dev.to/thekilteddev/invoked-not-executed-1cg8</link>
      <guid>https://dev.to/thekilteddev/invoked-not-executed-1cg8</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/BfC0iFzhFFs"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;A deep-research request to the top-tier model tore through three consecutive five-hour usage&lt;br&gt;
windows, the rolling quota Claude enforces before a session has to stop and reset, to answer a&lt;br&gt;
single question.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Usage window&lt;/th&gt;
&lt;th&gt;Burned&lt;/th&gt;
&lt;th&gt;Time to burn it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Window 1&lt;/td&gt;
&lt;td&gt;54%&lt;/td&gt;
&lt;td&gt;about two minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Window 2&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;about four minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Window 3&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;finishing the job&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three consecutive five-hour usage windows against one question. That adds up to 194% - nearly two&lt;br&gt;
entire windows - and the first two were gone inside six minutes.&lt;/p&gt;

&lt;p&gt;Nobody had done anything unusual. Someone had just asked the model to look something up properly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://thekilted.dev/route-dont-guess/" rel="noopener noreferrer"&gt;The routing policy this machine runs on&lt;/a&gt; has a rule for&lt;br&gt;
exactly this shape of task: work that's one step (search, fetch a source, extract a claim) and&lt;br&gt;
objectively checkable belongs on the cheapest tier that can do it, not on the model that costs the&lt;br&gt;
most per token. Deep research is built entirely out of that shape: search, fetch, extract, feeding&lt;br&gt;
a synthesis step that does need judgment. The rule already existed. It just never had a chance to&lt;br&gt;
apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the rule never fired
&lt;/h2&gt;

&lt;p&gt;A routing policy only engages at a decision point: the moment a session has to choose which tier&lt;br&gt;
does the next piece of work. Most of the policy's machinery assumes that moment exists. A plan gets&lt;br&gt;
broken into tasks, and each task is a place to ask who does this.&lt;/p&gt;

&lt;p&gt;A direct request, "deep-research this," skips all of that. It's answered as one thing, by whichever&lt;br&gt;
model receives it, in whichever way that model has of doing research. There's no task list to route&lt;br&gt;
pieces of. The search, the fetching, the extraction, the verification all happen inside a single&lt;br&gt;
reply from the most expensive available model, because nothing ever asked whether the next step&lt;br&gt;
needed to be there.&lt;/p&gt;

&lt;p&gt;The fix follows from the gap. Route research-shaped requests through a harness that does the&lt;br&gt;
decomposing on the way in, whatever words were used to ask. That covers all three shapes the ask&lt;br&gt;
takes: an explicit "research this," a model deciding on its own that a claim needs checking, or&lt;br&gt;
something as soft as "I'm not sure what's actually true here." All three land in the same place if&lt;br&gt;
answered directly. All three should hit the harness instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: 3 phrasings, 2 tiers
&lt;/h2&gt;

&lt;p&gt;Having independently found an adjacent problem (a third-party tool doing something similar had its&lt;br&gt;
own bug in the budget cap meant to prevent exactly this kind of burn), a sibling project was asked&lt;br&gt;
to check whether the routing fix triggered in practice. Six blind subagents, no shared context, one&lt;br&gt;
prompt each: three ways of asking for the same research, crossed with two model tiers. The topic&lt;br&gt;
was held constant, the enforcement status of the EU AI Act, so how the question was worded was the&lt;br&gt;
only deliberate variable.&lt;/p&gt;

&lt;p&gt;The intended way of measuring it failed. A subagent's own transcript file stays zero bytes even&lt;br&gt;
after the run has finished, so nobody could simply read off which tools had been called. The&lt;br&gt;
measurement fell back to asking each subject afterward what it had done, cross-checked against a&lt;br&gt;
tool-call count the harness reports and the agent has no hand in writing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The harness fired in half the runs. That looked like a pass. It wasn't one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What "invoked" was hiding
&lt;/h2&gt;

&lt;p&gt;Every run that triggered the harness received an instruction to call &lt;code&gt;Workflow&lt;/code&gt;, the dispatcher&lt;br&gt;
that fans the research out across sub-agents. &lt;code&gt;Workflow&lt;/code&gt; is not available inside a delegated&lt;br&gt;
subagent. Each of those runs found that out mid-task, noticed the gap, and quietly did the research&lt;br&gt;
a different way: by hand, with the tools it did have. Nothing crashed. Nothing logged an error. The&lt;br&gt;
final answer looked like a normal research answer, because it was one. Just not the one the rule&lt;br&gt;
was written to guarantee.&lt;/p&gt;

&lt;p&gt;One of the six was candid about exactly what was lost in translation. It had improvised its own&lt;br&gt;
version of the harness's fan-out step, sending several of its own sub-agents out to search in&lt;br&gt;
parallel, and reported the results converging as if that settled things. Asked afterward what its&lt;br&gt;
own five-agent agreement was worth, it corrected itself before anyone pushed back:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The convergence I reported between agents is corroboration, not adversarial verification, five&lt;br&gt;
agents drawing on an overlapping source pool can agree and still be wrong together.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The step that was skipped wasn't a decoration. It was the one part of the harness whose entire job&lt;br&gt;
is to catch exactly that kind of agreement that isn't evidence. A rule checking whether the harness&lt;br&gt;
was invoked would have called that run compliant. The harness had not, in the sense that mattered,&lt;br&gt;
run at all.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Harness invoked&lt;/th&gt;
&lt;th&gt;Fan-out executed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Explicit ask (sonnet)&lt;/td&gt;
&lt;td&gt;met&lt;/td&gt;
&lt;td&gt;broke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit ask (opus)&lt;/td&gt;
&lt;td&gt;met&lt;/td&gt;
&lt;td&gt;broke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No keyword (sonnet)&lt;/td&gt;
&lt;td&gt;broke&lt;/td&gt;
&lt;td&gt;never established&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No keyword (opus)&lt;/td&gt;
&lt;td&gt;broke&lt;/td&gt;
&lt;td&gt;never established&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soft / vague (sonnet)&lt;/td&gt;
&lt;td&gt;met&lt;/td&gt;
&lt;td&gt;broke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Soft / vague (opus)&lt;/td&gt;
&lt;td&gt;broke&lt;/td&gt;
&lt;td&gt;never established&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Invoked in three of six runs, executed in none. The three that invoked it reached for &lt;code&gt;Workflow&lt;/code&gt;,&lt;br&gt;
which a delegated subagent cannot call, and fell back to research by hand; the three that never&lt;br&gt;
invoked it never got far enough to find out. The right-hand column is empty either way, and that&lt;br&gt;
column is the one the rule was written to guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that looked best was the least trustworthy
&lt;/h2&gt;

&lt;p&gt;The one wording that worked reliably, the explicit "deep-research this," didn't work because the&lt;br&gt;
routing logic held. One of the successful runs said, unprompted, exactly why it had picked up on&lt;br&gt;
the request. The words read to it as plain English, and it matched them to the right tool on its&lt;br&gt;
own initiative. In its own account, the phrase was "hyphenated as a verb phrase," with "no slash,&lt;br&gt;
no backticks, no skill syntax."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nothing in how it was written signalled a tool call.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Rename the tool, or ask for the same research without those two words, and the coincidence that&lt;br&gt;
made the easy case look solved stops holding.&lt;/p&gt;

&lt;p&gt;The case that matters is a serious, specific question asked with no research vocabulary in it. That&lt;br&gt;
is the shape almost every real research need takes, and it missed on every model tested. That's the&lt;br&gt;
gap worth worrying about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed, and what didn't
&lt;/h2&gt;

&lt;p&gt;The rule was rewritten to check for evidence the harness had actually run: a count of sub-agents&lt;br&gt;
dispatched, a tally of claims independently verified, rather than trusting that the right words had&lt;br&gt;
been said. That closes the failure mode the test found: a compliant-looking run that quietly did&lt;br&gt;
none of the work the compliance was meant to certify.&lt;/p&gt;

&lt;p&gt;It doesn't close the other one. The wording that reliably works is still the wording that happens to&lt;br&gt;
match the tool's own name. The question that matters, the one with no research vocabulary in it,&lt;br&gt;
misses the harness on both models tried.&lt;/p&gt;

&lt;p&gt;Fixing what gets checked was the easy half. The other half is getting the harness to notice it is&lt;br&gt;
needed at all, from a question that never says so. That one is still open, and the coincidence that&lt;br&gt;
made the easy case look solved is the reason to expect it is the harder one.&lt;/p&gt;

&lt;p&gt;The rule that got rewritten, extracted and de-identified: &lt;a href="https://github.com/thekilteddev/routing-policy" rel="noopener noreferrer"&gt;the routing-policy&lt;br&gt;
repo&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>orchestration</category>
      <category>routing</category>
      <category>llmops</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>What really fits in 8GB VRAM</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Tue, 18 Aug 2026 19:03:52 +0000</pubDate>
      <link>https://dev.to/thekilteddev/what-really-fits-in-8gb-vram-4o02</link>
      <guid>https://dev.to/thekilteddev/what-really-fits-in-8gb-vram-4o02</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Mstw8fKvjB0"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Four VRAM numbers that looked wrong on an 8GB laptop, none of them a bug: what dxdiag actually&lt;br&gt;
reports, what the desktop holds back before a model even loads, and the two fit rules that came&lt;br&gt;
out of chasing all four down.&lt;/p&gt;

&lt;p&gt;Every question about running models locally reduces to one question: will it fit. Which model,&lt;br&gt;
which runtime, which quantisation, whether the thing is usable or a slideshow: all of it comes&lt;br&gt;
down to that one word. The trouble is that the number deciding the answer is never the number&lt;br&gt;
anything shows you.&lt;/p&gt;

&lt;p&gt;This is a laptop with an RTX 5060 and 8GB of VRAM. Four separate figures turned out to be&lt;br&gt;
measuring something adjacent to what actually mattered. Not one of them was wrong. Each was&lt;br&gt;
reporting a real quantity. Just not the quantity that decides whether a model loads.&lt;/p&gt;
&lt;h2&gt;
  
  
  The first number: shared memory
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;› &lt;span class="kd"&gt;dxdiag&lt;/span&gt; &lt;span class="na"&gt;/whql&lt;/span&gt;&lt;span class="nl"&gt;:off&lt;/span&gt; &lt;span class="na"&gt;/t &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kd"&gt;outfile&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run that on this machine, open the Display tab, and it reports the graphics card as having&lt;br&gt;
&lt;strong&gt;24,144 MB&lt;/strong&gt; of display memory. Twenty-four gigabytes, on a laptop sold as having eight. Directly&lt;br&gt;
underneath, the same panel breaks it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Display Memory:    24144 MB
Dedicated Memory:   7899 MB
Shared Memory:     16245 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;7,899 plus 16,245 is 24,144. The arithmetic is honest. Display memory is the dedicated VRAM on the&lt;br&gt;
card plus a slice of system RAM the driver treats as overflow, half the machine's 31.7GB, the&lt;br&gt;
standard Windows allocation. Only the dedicated figure decides whether a model loads.&lt;/p&gt;

&lt;p&gt;Two details make the trap worse than a single mislabelled number. The shared pool isn't the&lt;br&gt;
graphics card's at all, and every display adapter on the machine claims it: the integrated Intel&lt;br&gt;
graphics, the RTX 5060, and two DisplayLink devices all report the same 16,245 MB. Add up what the&lt;br&gt;
machine appears to have and the total overstates the truth several times over. The dedicated&lt;br&gt;
figure isn't perfectly stable either: a model-fitting tool run on the same box reported 7.96GB&lt;br&gt;
where dxdiag says 7,899 MB, close enough to be the same answer, far enough apart that a fit&lt;br&gt;
calculation cutting it fine will disagree with itself depending on which tool it asked.&lt;/p&gt;

&lt;p&gt;The two pools behave completely differently under load. VRAM is fast and finite. The shared&lt;br&gt;
region is system RAM wearing a costume: spilling into it doesn't fail, it just gets slow, and a&lt;br&gt;
slowdown is worse than a failure because a failure tells you something is wrong while a slowdown&lt;br&gt;
just lets you keep going, wondering why everything feels off. A 9B model at 32K context sits&lt;br&gt;
entirely in VRAM at 5.9GB and generates 51.5 tokens/sec. Push the context to 64K and it needs&lt;br&gt;
7.5GB, spills to an 18/82 split, and drops to 22.6 tokens/sec. Same model, same machine, same&lt;br&gt;
quantisation. One setting, less than half the speed.&lt;/p&gt;

&lt;p&gt;None of this requires taking anyone's word for it: the breakdown is four lines down the Display&lt;br&gt;
tab on every Windows machine there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second number: your desktop already has some of it
&lt;/h2&gt;

&lt;p&gt;The dedicated 7.9GB isn't yours either. Under normal use (browser open, editor open, the desktop&lt;br&gt;
doing its job) somewhere between 2 and 3.5GB is already spoken for before a single model loads.&lt;br&gt;
Not reserved in a way you can reclaim by closing one app. Just gone, in the ordinary course of&lt;br&gt;
using the computer for anything other than inference.&lt;/p&gt;

&lt;p&gt;The spread matters as much as the figure. Measured on this machine with a browser, a notes app, a&lt;br&gt;
spreadsheet and around thirty other things touching the GPU, it sits at almost exactly 2GB. On a&lt;br&gt;
busier day it has been half again that. On the quiet afternoon the benchmark below was recorded,&lt;br&gt;
it was under 1GB.&lt;/p&gt;

&lt;p&gt;That shows up in the benchmark record as an awkward footnote. Here is the same 36B&lt;br&gt;
mixture-of-experts model, three ways, every row measured at the same 32K context:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Gen t/s&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;th&gt;When you can use it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp, 8 expert layers on GPU&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27.1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.4GB&lt;/td&gt;
&lt;td&gt;Apps closed only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp, 6 expert layers on GPU&lt;/td&gt;
&lt;td&gt;26.3&lt;/td&gt;
&lt;td&gt;6.4GB&lt;/td&gt;
&lt;td&gt;Recorded safe default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama, automatic 80/20 CPU/GPU split&lt;/td&gt;
&lt;td&gt;23.4&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Default behaviour&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same day, same machine, flash attention on, KV cache quantised to q8_0. Ambient GPU load during&lt;br&gt;
measurement was unusually low, between 0.3 and 0.8GB; normal desktop load is around 3.5GB.&lt;/p&gt;

&lt;p&gt;That fastest row leaves around 800 MB of headroom, and it was measured on that unusually quiet&lt;br&gt;
afternoon. Under normal desktop load it doesn't fit at all. So the best number in the table is one&lt;br&gt;
that can't be reproduced while actually using the computer, and the benchmark file says so&lt;br&gt;
directly rather than quoting the headline figure and moving on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two rules that came out of it
&lt;/h2&gt;

&lt;p&gt;After enough of this, the fit question collapsed into two rules that are worth more than any&lt;br&gt;
individual measurement.&lt;/p&gt;

&lt;p&gt;Dense models: the file has to be about 7GB or smaller at 4-bit quantisation, and it has to run&lt;br&gt;
entirely on the GPU. If it spills, the speed lost is more than a bigger model would have gained.&lt;/p&gt;

&lt;p&gt;Mixture-of-experts models: the file has to fit in system RAM (31.7GB here) with the experts&lt;br&gt;
offloaded to the CPU. VRAM only needs to hold the attention layers and the key-value cache.&lt;/p&gt;

&lt;p&gt;The second rule produces the result that governs everything this project runs: a large&lt;br&gt;
mixture-of-experts model fits comfortably where a much smaller dense model doesn't. A 30B MoE&lt;br&gt;
model with a 19GB file runs at 32.9 tokens/sec generation on a 69/31 CPU/GPU split, at 16K&lt;br&gt;
context. A 14B dense model is simply ruled out, and so is the default pull of a well-known 9.6GB&lt;br&gt;
dense model, on the dense rule alone.&lt;/p&gt;

&lt;p&gt;That context qualifier isn't decoration. Speed figures are only comparable within a single&lt;br&gt;
context size, which is why every number here carries one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Parameter count isn't the constraint. Architecture is: a model three times the size fits because&lt;br&gt;
of how it's shaped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's invisible from any spec sheet that lists parameters and file sizes. Not everything needs a&lt;br&gt;
hands-on trial to rule out, either. An 80B MoE model was ruled out at the research stage: its&lt;br&gt;
smallest 4-bit quantisation is 38.4GB, more than the machine has RAM for at all. No test required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third number: the config that was right when it was written
&lt;/h2&gt;

&lt;p&gt;Six weeks in, a model that had been working for over a week stopped loading. Every attempt died&lt;br&gt;
with a CUDA out-of-memory error, and the first answer was wrong.&lt;/p&gt;

&lt;p&gt;The server log said it had offloaded 49 of 49 layers to the GPU, which reads unambiguously as "it&lt;br&gt;
tried to load the whole thing into VRAM instead of doing the CPU split that had been working." A&lt;br&gt;
satisfying scheduler bug, except it wasn't what happened. Reading the full memory breakdown&lt;br&gt;
instead of the first suspicious line showed the actual buffer allocation: 3.2 to 4GB in the GPU&lt;br&gt;
buffer against 13.5 to 14.3GB in pinned host memory. The CPU offload was correct the whole time.&lt;br&gt;
That log line's wording misled; it wasn't evidence.&lt;/p&gt;

&lt;p&gt;The real cause turned up in the runtime's own fit-calculation logging, and it wasn't a bug either.&lt;br&gt;
The fit calculation had correctly worked out that 43 of 49 layers would overflow to host RAM, and&lt;br&gt;
projected 5,123 MiB of VRAM use against 6,196 MiB free. A plausible-looking fit.&lt;/p&gt;

&lt;p&gt;What had actually happened was that the model's local alias had been silently recreated eight&lt;br&gt;
days earlier, inheriting the project's default context setting of 32,768, and this particular&lt;br&gt;
model had never been verified at that context. It had been benchmarked at 16K.&lt;/p&gt;

&lt;p&gt;The evidence was sitting in the original benchmark file the whole time, contradicting itself. The&lt;br&gt;
prose describing the working run states the context parameter as 32,768. The speed table further&lt;br&gt;
down labels the identical CPU/GPU split "@16K." Both written the same day, about the same run.&lt;br&gt;
Nobody reads a benchmark file for internal consistency after writing it, so the contradiction sat&lt;br&gt;
there quietly. A single-variable test settled it: at 16,384 the model loads and generates every&lt;br&gt;
time; at 32,768 it fails every time. The fix was re-pinning the alias: a configuration&lt;br&gt;
correction, not a code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fourth number: free memory doesn't predict this
&lt;/h2&gt;

&lt;p&gt;The most useful part of that failure is the bit that contradicts intuition. The out-of-memory&lt;br&gt;
error didn't depend on how much VRAM was free. Retested with the model fully unloaded and 6.1GB&lt;br&gt;
available, it still failed, ruling out the obvious explanation that some other app was&lt;br&gt;
transiently holding memory. At 32K context, the model's key-value cache plus its compute buffers&lt;br&gt;
exceed what fits after the CPU offload split, regardless of what's free. Headroom isn't the&lt;br&gt;
variable. The shape of the allocation is.&lt;/p&gt;

&lt;p&gt;That has a practical edge: a fit-estimating tool used on this machine calculates against total&lt;br&gt;
VRAM rather than free VRAM, so its recommendations need hand-adjusting for whatever the desktop is&lt;br&gt;
already holding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this adds up to
&lt;/h2&gt;

&lt;p&gt;Four numbers, four different ways of being adjacent to the truth. Windows reports memory that&lt;br&gt;
includes a region you don't want to touch. The desktop silently holds a few gigabytes of what&lt;br&gt;
remains. A log line describes full GPU offload while performing a CPU split. A config file records&lt;br&gt;
a context the model was never tested at, and a fitting tool measures against a total nobody&lt;br&gt;
actually has.&lt;/p&gt;

&lt;p&gt;None of these is a bug. Every one is a real measurement of something, presented plainly, by&lt;br&gt;
software with no intention to mislead. They're wrong only in the sense that they answer a question&lt;br&gt;
next to the one being asked, which is why the two fit rules are worth more than the measurements&lt;br&gt;
that produced them. The rules survive a driver update, a runtime version bump, a model release.&lt;br&gt;
The numbers don't, and the ones that look most authoritative are usually the ones measured on an&lt;br&gt;
unusually quiet afternoon.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/what-really-fits-in-8gb-vram/" rel="noopener noreferrer"&gt;thekilted.dev/what-really-fits-in-8gb-vram&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>localllm</category>
      <category>vram</category>
      <category>gpu</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
