<?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>Nothing to go stale</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sat, 07 Nov 2026 08:49:28 +0000</pubDate>
      <link>https://dev.to/thekilteddev/nothing-to-go-stale-16o8</link>
      <guid>https://dev.to/thekilteddev/nothing-to-go-stale-16o8</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/Wj4-bYSqPbo" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;One GPU, one machine, and for now exactly one tool that needs it: a local model doing GPU-bound&lt;br&gt;
work a couple of times a week for one project that calls it. Small enough that nothing had broken&lt;br&gt;
yet. Also the moment a design decision is cheapest to get right, before real usage patterns build&lt;br&gt;
up around whatever gets chosen first.&lt;/p&gt;

&lt;p&gt;The proposal that came my way was the standard answer to "two things might want the same resource&lt;br&gt;
at once": a lock file. Before using the GPU, claim it by creating a marker. Before finishing, remove&lt;br&gt;
the marker. Anything that shows up while the marker exists waits its turn. It's a well-understood&lt;br&gt;
pattern, and the version proposed was already a careful one: atomic creation so two claims can't&lt;br&gt;
race each other, enough content in the file to tell a genuinely stale lock from a live one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a lock file is actually for
&lt;/h2&gt;

&lt;p&gt;I looked closely at why a lock needs to be careful in the first place: it isn't protecting against&lt;br&gt;
the resource being busy. It's protecting against the lock itself lying. Every piece of a well-built&lt;br&gt;
lock's design — atomic creation, a check for whether the lock has gone stale, cleanup on every exit&lt;br&gt;
— exists to defend against failure modes the lock introduces just by existing. A resource that was&lt;br&gt;
never contended in the first place doesn't need any of that. The contention was never the hard&lt;br&gt;
part. The bookkeeping was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't keep a record, ask
&lt;/h2&gt;

&lt;p&gt;The hardware itself already knows exactly what's using it, right now, with no possibility of going&lt;br&gt;
stale, because it isn't a record of a past claim. It's the current state, queried live. A single&lt;br&gt;
command asks the driver directly: what's running, how much memory is it holding. No file to&lt;br&gt;
create, none that can outlive the process that made it, no PID to check against a process table&lt;br&gt;
that might have recycled it by now.&lt;/p&gt;

&lt;p&gt;And it fits the actual failure this machine has had: not two processes racing to grab the GPU at&lt;br&gt;
the same instant, but one process hanging without dying and quietly sitting on memory the next job&lt;br&gt;
needs. A lock file wouldn't catch that at all. A hung-but-alive process holds its lock exactly as&lt;br&gt;
validly as a healthy one. Asking the hardware catches it immediately, because the memory is either&lt;br&gt;
there or it isn't, whatever any marker file claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a lock file has to get right:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create the marker atomically, so two claims can't race&lt;/li&gt;
&lt;li&gt;Remove it again, on every path out&lt;/li&gt;
&lt;li&gt;A holder that died without removing it&lt;/li&gt;
&lt;li&gt;A process ID the operating system has already reused&lt;/li&gt;
&lt;li&gt;A holder that hangs without dying, and keeps the lock&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What a live query has to get right:&lt;/strong&gt; nothing. There is no stored claim, so there is nothing that&lt;br&gt;
can disagree with the hardware.&lt;/p&gt;

&lt;p&gt;Four of the five are obligations the lock creates for itself. The fifth, creating the marker&lt;br&gt;
atomically, guards a simultaneous race, which is not the failure this machine has actually had.&lt;/p&gt;

&lt;p&gt;The one thing a lock file offers that a live query doesn't — a clean error message naming who's&lt;br&gt;
holding the resource — is available from that same query. The command that shows what's running&lt;br&gt;
also shows which process ID owns it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Zero staleness handling needed, because there's no state to go stale. Not a cleverer lock, no lock at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A second cut, caught by asking someone to disagree
&lt;/h2&gt;

&lt;p&gt;A related piece of the same proposal suggested retry logic: if the resource is busy, wait and try&lt;br&gt;
again automatically. Reasonable-sounding, and it was cut too — by an independent review pass&lt;br&gt;
commissioned alongside the decision itself, brought in to look for problems rather than confirm&lt;br&gt;
the plan.&lt;/p&gt;

&lt;p&gt;An unattended process retrying its way onto shared hardware is precisely what this project's own&lt;br&gt;
rule against unattended execution already exists to prevent. Elsewhere in the same document, the&lt;br&gt;
proposal that suggested the retry logic had made that exact argument in a different context.&lt;br&gt;
Nobody had done anything dishonest. It's an easy inconsistency to write past when you're the one&lt;br&gt;
building the argument in the first place. The retry logic and the no-autonomous-execution&lt;br&gt;
principle were reasoned about separately, minutes apart, and never checked against each other&lt;br&gt;
until someone whose only job was to do exactly that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the proposal asked for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the hardware is busy, wait and try again&lt;/li&gt;
&lt;li&gt;Automatically, unattended, with nobody watching&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What the same document ruled out:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anything that can fire on its own&lt;/li&gt;
&lt;li&gt;Anything that runs with no person present to catch it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One document, two conclusions, minutes apart. Side by side they are incompatible; read in the order&lt;br&gt;
they were written, neither one looks wrong.&lt;/p&gt;

&lt;p&gt;Fail fast instead. If the resource is busy, say so and stop. A human decides when to try again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What made the lazy answer the right one
&lt;/h2&gt;

&lt;p&gt;None of this came from my distrust of locks in general, or a rule that says avoid new machinery. It&lt;br&gt;
came from asking what problem the proposed fix was solving, and finding that its complexity existed&lt;br&gt;
only to manage its own side effects. The resource was never the source of the risk. The simplest&lt;br&gt;
version of the design didn't win by being simpler. It won because it was more correct, for the&lt;br&gt;
failure that had actually happened, and had nothing left over to go wrong.&lt;/p&gt;




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

</description>
      <category>softwareengineering</category>
      <category>systemdesign</category>
      <category>debugging</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The review that caught itself</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sat, 31 Oct 2026 07:54:50 +0000</pubDate>
      <link>https://dev.to/thekilteddev/the-review-that-caught-itself-1p0n</link>
      <guid>https://dev.to/thekilteddev/the-review-that-caught-itself-1p0n</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/bt_CALJF4jQ" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;Field notes on a review process. The external blog post at the centre of this, its author, and the&lt;br&gt;
project that relayed it are all deliberately unnamed — the piece is about the reasoning, not about&lt;br&gt;
anyone's work in particular.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea that survived
&lt;/h2&gt;

&lt;p&gt;A sibling project passed along a blog post about a different team's local-agent harness: sharp,&lt;br&gt;
well-argued, built around one specific rule. Don't rely on an instruction the agent has to remember&lt;br&gt;
to follow. Build the constraint into the tool so it physically can't be ignored. A gate, not a rule.&lt;/p&gt;

&lt;p&gt;That part held up completely, and for a good reason — it wasn't new here. This project had already&lt;br&gt;
been enforcing the same kind of constraint by hand: a written rule about defining a shared record's&lt;br&gt;
fields before anything used them, which kept getting silently ignored by whatever was supposed to&lt;br&gt;
follow it. The response stopped being "write the rule more clearly" and started being "make it&lt;br&gt;
structurally impossible to skip." What eventually held was a validator that refused the write.&lt;/p&gt;

&lt;p&gt;Two teams reaching the identical fix independently is stronger evidence than either team's&lt;br&gt;
reasoning alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim that didn't
&lt;/h2&gt;

&lt;p&gt;The draft that came out of the brainstorm made a stronger claim than that. It said the same&lt;br&gt;
gate-not-a-rule fix had now shown up three independent times, treating that as a threshold crossed.&lt;/p&gt;

&lt;p&gt;It hadn't.&lt;/p&gt;

&lt;p&gt;One of the three cited instances was the same internal case that had made the principle ring true&lt;br&gt;
in the first place. A real problem — and still not an agent ignoring an instruction, which is what&lt;br&gt;
the count was counting. Right case, wrong bucket. The other two both arrived through the same&lt;br&gt;
single relay, from a source that was never independently checked. Three confident-sounding data&lt;br&gt;
points, and on inspection, one didn't belong and the other two were really one.&lt;/p&gt;

&lt;p&gt;That correction came from an independent review, not from anyone re-reading their own work more&lt;br&gt;
carefully. It's the specific thing that kind of review exists to catch: a claim that sounds&lt;br&gt;
verified because it was written among evidence, when the evidence was one piece of secondhand&lt;br&gt;
testimony, counted twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quieter catch
&lt;/h2&gt;

&lt;p&gt;Buried in the relay was a line characterising a design's human-approval step as "a stopgap, not a&lt;br&gt;
deliberate choice" — and the draft presented that line as something the original source had said.&lt;br&gt;
The review checked, and it hadn't. That framing was the relaying project's own reading of the&lt;br&gt;
material, not a claim the original author made. A fair reading, maybe, but a different kind of&lt;br&gt;
claim than "the source says this", and the draft had quietly upgraded one into the other.&lt;/p&gt;

&lt;p&gt;No one had lied. No one had checked either — the review was the first pass that did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually adopted
&lt;/h2&gt;

&lt;p&gt;One principle, promoted to policy on its own merits, with the overclaimed count corrected. One&lt;br&gt;
design pattern, confirmed as already covered by an existing rule — whoever built something never&lt;br&gt;
gets to be the only one who says it works — and not built. Two permanent guardrails went into the&lt;br&gt;
record anyway, in case it ever is.&lt;/p&gt;

&lt;p&gt;Nothing shipped that hadn't survived the review. What I keep sitting with is how easily the&lt;br&gt;
overclaimed version would have shipped without it. Not because the underlying idea was wrong, but&lt;br&gt;
because a genuinely good idea makes a shaky supporting claim easy to wave through as attached&lt;br&gt;
evidence instead of noticing it needed its own check.&lt;/p&gt;




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

</description>
      <category>codereview</category>
      <category>evidence</category>
      <category>softwareengineering</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Nineteen calls, every time</title>
      <dc:creator>the kilted dev</dc:creator>
      <pubDate>Sat, 24 Oct 2026 07:40:01 +0000</pubDate>
      <link>https://dev.to/thekilteddev/nineteen-calls-every-time-1o4b</link>
      <guid>https://dev.to/thekilteddev/nineteen-calls-every-time-1o4b</guid>
      <description>&lt;div&gt;
    &lt;iframe src="https://www.youtube.com/embed/F8dzQb2G-Oo" width="315" height="560"&gt;
    &lt;/iframe&gt;
  &lt;/div&gt;


&lt;p&gt;Claude's own desktop app kept dying outright, the whole window gone, no warning. Not a hang, not a&lt;br&gt;
freeze that eventually recovers: the process behind the screen simply stopped existing. It happened&lt;br&gt;
five times in one day alone, on a Lenovo Legion 5 laptop. Each one logged the identical signature:&lt;br&gt;
the GPU process reported gone, reason crashed, exit code 101457950, every single time.&lt;/p&gt;

&lt;p&gt;I ruled out the obvious suspects first, one at a time, and each one cleared.&lt;/p&gt;

&lt;p&gt;Memory looked fine going into the crash: system RAM had over ten gigabytes free at the moment&lt;br&gt;
checked, nowhere near exhausted. The graphics driver never logged a reset around any crash checked&lt;br&gt;
for one, no driver-level event in the system log at all. Nothing was left over from a&lt;br&gt;
previous crash either: once the app went down, the only thing still running afterward was an&lt;br&gt;
unrelated command-line helper, so there was no stale process holding a lock that could explain the&lt;br&gt;
next crash starting from a bad state. Three clean suspects, three dead ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that was hiding in a different log entirely
&lt;/h2&gt;

&lt;p&gt;The actual cause turned up somewhere nobody was looking directly for it: a completely separate log&lt;br&gt;
file, tracking browser-level activity rather than the app's own process events. A specific WebGL&lt;br&gt;
call — &lt;code&gt;getInternalformatParameter&lt;/code&gt;, the kind a web page makes to ask the graphics driver about&lt;br&gt;
supported texture formats — showed up in a tight burst right before every crash. Not once or twice. Every single time, across five&lt;br&gt;
separately timed occurrences spanning hours, that burst was exactly the same size: nineteen calls.&lt;br&gt;
Never eighteen. Never twenty. A fixed number repeating exactly, five times, is not what a random&lt;br&gt;
failure looks like. It's what a fixed sequence running to completion looks like, every time it&lt;br&gt;
runs at all.&lt;/p&gt;

&lt;p&gt;The trigger was a browser preview pane inside the app, opened by whatever the user was doing at the&lt;br&gt;
time, loading a page that happened to run a script probing WebGL capabilities as part of its own&lt;br&gt;
fingerprinting or feature detection. Each crash landed five to six seconds after the preview pane&lt;br&gt;
had opened. The nineteen-call burst sat right in that gap, every time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start&lt;/strong&gt; — the browser preview pane opens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the gap&lt;/strong&gt; — a page inside it runs nineteen WebGL calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5-6s later&lt;/strong&gt; — the GPU process is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same three steps behind all five crashes in one day. The nineteen calls sat in the gap between&lt;br&gt;
the pane opening and the process dying, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening the pane wasn't enough by itself
&lt;/h2&gt;

&lt;p&gt;One earlier preview-pane open that same day had no burst afterward, and no crash. That's the detail&lt;br&gt;
that rules out "the preview pane itself is the problem." The pane can open cleanly all day. What&lt;br&gt;
kills the process is specifically a loaded page inside it running that exact probe sequence to&lt;br&gt;
completion, not the act of opening the pane at all.&lt;/p&gt;

&lt;p&gt;A separate warning line, about a GPU power-preference option being ignored on this platform, showed&lt;br&gt;
up earlier that same morning with nothing bad following it: checked five seconds either side, no&lt;br&gt;
burst, no crash. That same warning also appears at the very top of every one of the actual crash&lt;br&gt;
sequences, immediately before the nineteen calls start. So the warning by itself isn't the trigger.&lt;br&gt;
It's a harmless line that happens to sit right before the real trigger, whenever the real trigger&lt;br&gt;
runs at all, which made it worth ruling out on its own rather than assuming it was just noise.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suspect&lt;/th&gt;
&lt;th&gt;What the check found&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory pressure&lt;/td&gt;
&lt;td&gt;Over ten gigabytes of system RAM still free at the moment checked&lt;/td&gt;
&lt;td&gt;Ruled out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A graphics-driver reset&lt;/td&gt;
&lt;td&gt;No driver-level event in the system log around any crash checked for one&lt;/td&gt;
&lt;td&gt;Ruled out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A stale process from the last crash&lt;/td&gt;
&lt;td&gt;Only an unrelated command-line helper still running afterward&lt;/td&gt;
&lt;td&gt;Ruled out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opening the preview pane, on its own&lt;/td&gt;
&lt;td&gt;One open earlier the same day, no burst after it and no crash&lt;/td&gt;
&lt;td&gt;Ruled out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A GPU power-preference warning&lt;/td&gt;
&lt;td&gt;Checked five seconds either side, no burst and no crash following it&lt;/td&gt;
&lt;td&gt;Ruled out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A loaded page running the probe to completion&lt;/td&gt;
&lt;td&gt;A burst of exactly nineteen calls in the gap before every one of the five crashes&lt;/td&gt;
&lt;td&gt;Real cause&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A sixth possible crash, found and deliberately left uncounted
&lt;/h2&gt;

&lt;p&gt;Going back further in the logs turned up one more candidate burst, eight days before the day with&lt;br&gt;
five crashes, and it doesn't get counted. Here's why. The burst is there, the right size, in the&lt;br&gt;
right log. What isn't there is the matching crash line. The specific stretch of log covering that&lt;br&gt;
exact window had already rotated out of every log file still on disk by the time anyone went&lt;br&gt;
looking. What's on disk instead is a fresh app launch about nine minutes after the burst, with the&lt;br&gt;
previous launch recorded two days earlier, meaning something ended that long-running session before&lt;br&gt;
the new one started, just not on camera. A restart nine minutes after an unexplained burst, with&lt;br&gt;
nothing describing how the previous session actually ended, is consistent with the same crash. It&lt;br&gt;
is not proof of it. Six of the bursts line up with a confirmed crash: one five days earlier, and&lt;br&gt;
all five from the day of the five crashes. The unconfirmed one from further back stays unconfirmed,&lt;br&gt;
named as a maybe rather than quietly rounded into the count. If it belongs in the total,&lt;br&gt;
that's a call for whoever owns the running tally next, made with the gap in the evidence attached,&lt;br&gt;
not asserted on the strength of a nine-minute coincidence alone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Occurrence&lt;/th&gt;
&lt;th&gt;19-call burst&lt;/th&gt;
&lt;th&gt;Crash on record&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The five in one day&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five days earlier&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eight days earlier&lt;/td&gt;
&lt;td&gt;found&lt;/td&gt;
&lt;td&gt;could not be established&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven bursts in the logs, six of them beside a crash on the record. The seventh is left out of the&lt;br&gt;
count rather than rounded into it.&lt;/p&gt;

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

&lt;p&gt;No crash-diagnostic file was produced by any of the five crashes: the folder where the app would&lt;br&gt;
normally leave one after a hard failure stayed empty every time. The bug is filed upstream as&lt;br&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/80444" rel="noopener noreferrer"&gt;anthropics/claude-code#80444&lt;/a&gt;, alongside&lt;br&gt;
around ten duplicate reports, reproduced across NVIDIA, AMD, and Intel graphics on both Windows 10&lt;br&gt;
and 11, so it isn't specific to this one laptop. All of them are still open. I put a comment on&lt;br&gt;
that issue with this machine's own findings, and the thread is still getting daily activity from&lt;br&gt;
other people hitting the same crash. In the meantime the only mitigation that actually works is not&lt;br&gt;
opening that specific browser preview feature at all, and driving anything that needs a real&lt;br&gt;
browser through a separate, standalone tool instead.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;None of this was hard to check, once the search moved from guessing at causes to reading the log&lt;br&gt;
that actually recorded what happened.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The memory number, the driver log, the leftover-process check: all fast, all clean, all wrong turns&lt;br&gt;
that had to be ruled out anyway before the real pattern, sitting in a log nobody had thought to grep&lt;br&gt;
yet, showed up in exactly the same shape five times in a row.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thekilted.dev/nineteen-calls-every-time/" rel="noopener noreferrer"&gt;thekilted.dev/nineteen-calls-every-time&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>gpu</category>
      <category>softwareengineering</category>
      <category>buildinpublic</category>
    </item>
    <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>
  </channel>
</rss>
