<?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: John</title>
    <description>The latest articles on DEV Community by John (@hexisteme).</description>
    <link>https://dev.to/hexisteme</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%2F3997679%2F9c5c7fc3-a104-4bdc-bcb4-c02790d94d63.png</url>
      <title>DEV Community: John</title>
      <link>https://dev.to/hexisteme</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hexisteme"/>
    <language>en</language>
    <item>
      <title>Stop Hooks as Hard Constraints: Enforcing Claude Code Behavior Outside the Model</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 13 Jul 2026 09:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model-1g6b</link>
      <guid>https://dev.to/hexisteme/stop-hooks-as-hard-constraints-enforcing-claude-code-behavior-outside-the-model-1g6b</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/stop-hook-gates.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run a fleet of Claude Code agents, and for a long time I tried to enforce behavioral rules the obvious way: write them into the system prompt and hope they hold. They mostly did — until pressure showed up. A plausible-sounding challenge from me, a long task, a shortcut that saves a turn, and the model would quietly drop a rule it had followed all session. Prompt instructions are soft constraints. They shape behavior probabilistically, and when they conflict with RLHF-trained patterns — deference to the user, hedged language, listing options instead of committing to one — the RLHF training usually wins, especially under pressure.&lt;/p&gt;

&lt;p&gt;Stop hooks are what I actually rely on now. They're hard constraints: shell scripts that run after Claude Code generates a response and before I see it, and they can force a revision the model can't talk its way around.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A stop hook is a shell script registered in &lt;code&gt;settings.json&lt;/code&gt; under a &lt;code&gt;Stop&lt;/code&gt; event. It runs after every response, receives the full session as JSON on stdin, and either exits &lt;code&gt;0&lt;/code&gt; (allow it through) or exits &lt;code&gt;2&lt;/code&gt; with a message (block it and force a revision Claude must address). Because it runs outside the model, it's enforced, not suggested.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How stop hooks actually work
&lt;/h2&gt;

&lt;p&gt;The registration itself is small:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;~/.claude/settings.json&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="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;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"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;"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;"~/.claude/hooks/stop_combined.sh"&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;The script receives the full session context as JSON on stdin and can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exit &lt;code&gt;0&lt;/code&gt; — allow the response through&lt;/li&gt;
&lt;li&gt;Exit &lt;code&gt;2&lt;/code&gt; — block it and inject a correction message Claude must address&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The correction message comes back as feedback, and Claude generates a new response. I only ever see the final, corrected output — the hook's intervention is invisible unless I go looking for it in the transcript.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AND-gate pattern
&lt;/h2&gt;

&lt;p&gt;A hook that fires on a regex match alone has an obvious failure mode: false positives. A rule like "block responses that ask the user to decide" will also block a legitimate clarifying question like "what's your timeline for this?" That's not a failure mode I can tolerate in something that blocks output on every turn.&lt;/p&gt;

&lt;p&gt;The fix is to require two independent conditions before firing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;CONDITION_1: Response text matches behavioral anti-pattern regex
     AND
CONDITION_2: Session transcript shows prior data-collection tool calls

&lt;span class="c"&gt;# Only fire if BOTH are true&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$RESPONSE_MATCHES&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$HAD_TOOL_CALLS&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;block_with_message
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool-call evidence half is what makes this work. If Claude asks a clarifying question &lt;em&gt;without&lt;/em&gt; having done any analysis first, that's a legitimate question — let it through. If it's deferring back to me &lt;em&gt;after&lt;/em&gt; running searches and reading files, it already has what it needs and is offloading work it should be finishing itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production hook 1: decision-ownership enforcement
&lt;/h2&gt;

&lt;p&gt;The failure mode here is specific: RLHF trains models to hand judgment back to the user. "Which approach would you prefer?" after already analyzing both options. "It depends on your priorities" after having every piece of information needed to just recommend one. That isn't politeness — it's transferring cognitive labor that was the whole point of asking in the first place.&lt;/p&gt;

&lt;p&gt;The hook, &lt;code&gt;stop_decision_ownership_check.sh&lt;/code&gt;, works like this (condensed from the production script):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Read session JSON from stdin&lt;/span&gt;
&lt;span class="nv"&gt;SESSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Extract last response text&lt;/span&gt;
&lt;span class="nv"&gt;RESPONSE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,sys
d=json.load(sys.stdin)
msgs = d.get('messages', [])
for m in reversed(msgs):
    if m.get('role') == 'assistant':
        content = m.get('content', '')
        if isinstance(content, list):
            print(' '.join(b.get('text','') for b in content if b.get('type')=='text'))
        else:
            print(content)
        break
"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Check for deference patterns&lt;/span&gt;
&lt;span class="nv"&gt;DEFER_REGEX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'(어느.*(좋아|나을|선택)|당신이.*(정하|결정)|뭐가.*좋을|what.*prefer|which.*would you|up to you|your call|depends on your)'&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESPONSE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEFER_REGEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# No match, allow&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Check for prior tool evidence (AND gate)&lt;/span&gt;
&lt;span class="nv"&gt;TOOL_CALLS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,sys
d=json.load(sys.stdin)
count = sum(1 for m in d.get('messages',[])
            for b in (m.get('content',[]) if isinstance(m.get('content'), list) else [])
            if isinstance(b,dict) and b.get('type') in ('tool_use','tool_result'))
print(count)
"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOOL_CALLS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 2 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# No tool evidence, likely a legitimate question&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Nag-once: compute hash, check if already nag'd&lt;/span&gt;
&lt;span class="nv"&gt;HASH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESPONSE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256 | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-16&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;NAG_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/.hook_nag_ownership_&lt;/span&gt;&lt;span class="nv"&gt;$HASH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# Already nag'd this exact response&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DECISION_OWNERSHIP: You collected data then deferred the decision back. Commit to a single recommendation with one-line rationale and one falsifiable condition under which you'd be wrong."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regex mixes Korean and English deference markers on purpose — I work across both languages, and the pattern has to catch either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production hook 2: sycophancy prevention
&lt;/h2&gt;

&lt;p&gt;The second failure mode is worse, because it's invisible in the output. When challenged, Claude reverses its position at a 98% rate — that's from Anthropic's own published sycophancy research (arXiv 2310.13548). Not because the challenge supplied new evidence, but because RLHF rewarded agreement. Claude doesn't announce the capitulation ("you're right, I was wrong") — it just quietly shifts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stop_challenge_reverify.sh&lt;/code&gt; catches the pattern with a three-part gate: challenge, capitulation, and the absence of any actual verification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Gate: (challenge in user message) AND (capitulation in response) AND (no verification evidence)&lt;/span&gt;
&lt;span class="c"&gt;# USER_MSG and RESPONSE are pulled from the session transcript the same way hook 1 extracts RESPONSE&lt;/span&gt;

&lt;span class="nv"&gt;CHALLENGE_REGEX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'(아닌데|그건 아니|틀렸|잘못|다시 생각|actually|that'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'s wrong|not quite|disagree|incorrect)'&lt;/span&gt;
&lt;span class="nv"&gt;CAPITULATE_REGEX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'(맞네요|맞습니다|수정|맞는 말씀|사실|좋은 지적|you'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'re right|good point|I was wrong|actually yes|I misread)'&lt;/span&gt;
&lt;span class="nv"&gt;VERIFY_REGEX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'(검증|계산|확인|출처|논문|측정|verified|calculated|source|measured|according to)'&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHALLENGE_REGEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESPONSE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CAPITULATE_REGEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESPONSE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VERIFY_REGEX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
            &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CHALLENGE_REVERIFY: Position changed without new evidence. Choose:
HOLD — restate original position with grounds
CHANGE — cite specific new evidence or calculation that overrides original"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
            &lt;span class="nb"&gt;exit &lt;/span&gt;2
        &lt;span class="k"&gt;fi
    fi
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This hook has a deliberate blind spot: it can't catch silent capitulation, where the tone shifts without any explicit reversal language. I've accepted that trade-off — the priority is keeping false positives low on legitimate corrections, not catching every possible way a model can cave.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nag-once pattern
&lt;/h2&gt;

&lt;p&gt;Blocking indefinitely just creates a new failure: Claude keeps generating blocked responses and I get no output at all. Nag-once breaks that loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First violation — compute a sha256 of the response, write it to &lt;code&gt;/tmp/.hook_nag_{hash}&lt;/code&gt;, block with a message&lt;/li&gt;
&lt;li&gt;Second attempt with the identical response — the hash file already exists, so exit &lt;code&gt;0&lt;/code&gt; and let it through&lt;/li&gt;
&lt;li&gt;A genuinely different response — new hash, new nag file, block again if it's still violating&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This surfaces the issue exactly once and lets the conversation keep moving. I can still push back on the hook's correction if I disagree with it; that's fine — the hook's job is to force the question onto the table, not to win it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What hooks can't do
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Hooks operate on the final response text. They can't see the model's reasoning before it outputs anything, can't inject into the middle of a generation, and can't stop the model from starting down a bad path — only from completing it. Deeper behavioral constraints still have to live at the system-prompt and instruction level; hooks are the last line of enforcement, not the only one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Combining hooks
&lt;/h2&gt;

&lt;p&gt;I run several gates through one &lt;code&gt;stop_combined.sh&lt;/code&gt; that calls each in sequence and returns on the first block — I don't want two hooks piling correction messages on top of each other. Ordering matters: the highest-priority gates run first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# stop_combined.sh — ordered gate sequence&lt;/span&gt;
&lt;span class="nv"&gt;SESSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Gate 1: Decision ownership (highest priority)&lt;/span&gt;
&lt;span class="nv"&gt;RESULT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | ~/.claude/hooks/stop_decision_ownership_check.sh 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 2 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESULT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Gate 2: Sycophancy / challenge reverify&lt;/span&gt;
&lt;span class="nv"&gt;RESULT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SESSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | ~/.claude/hooks/stop_challenge_reverify.sh 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 2 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESULT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these hooks make the model smarter. What they do is close the gap between the behavior I actually want and the behavior RLHF defaults to under pressure, by moving enforcement outside the place where that pressure operates.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>claude</category>
      <category>llm</category>
    </item>
    <item>
      <title>Why I Rejected an Event Bus for My Solo Agent Fleet: State Is Truth, Events Are Rumors</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 13 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/why-i-rejected-an-event-bus-for-my-solo-agent-fleet-state-is-truth-events-are-rumors-59a3</link>
      <guid>https://dev.to/hexisteme/why-i-rejected-an-event-bus-for-my-solo-agent-fleet-state-is-truth-events-are-rumors-59a3</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/pull-vs-event-bus-agent-fleet.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run a personal fleet on one machine: a handful of small agent CLIs, a pile of cron jobs and LaunchAgents, and several MCP servers, some of them third-party tools I didn't write and can't modify. At some point I wanted a single inbox that could answer one question — what needs my attention right now? New outputs I haven't read yet. Decisions only a human can close. Dependencies that broke overnight without telling anyone.&lt;/p&gt;

&lt;p&gt;The obvious architecture for that is an event bus. I sketched it, then rejected it on purpose and built a pull-based design instead. The reasoning generalizes past my particular setup, so here it is.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For a small, high-churn fleet you don't fully control, pull (scan on-disk state) beats push (an event bus). An event bus needs every producer — including third-party tools you can't modify — to emit; anything that doesn't is silently invisible. A poller that reads on-disk state instead — file mtimes, health checks, process liveness — is self-healing: it reflects reality even for components that never report anything, and a new tool shows up the moment it writes a file, with zero instrumentation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The architecture I almost built
&lt;/h2&gt;

&lt;p&gt;The event-bus version looks clean on a whiteboard. Every component emits events — &lt;code&gt;job.finished&lt;/code&gt;, &lt;code&gt;output.created&lt;/code&gt;, &lt;code&gt;decision.pending&lt;/code&gt; — onto an append-only log. The inbox is just a reader of that log. Closing an item writes a close-event that advances whatever comes next. It's the textbook "single source of truth" pattern, and it's the first thing most people reach for.&lt;/p&gt;

&lt;p&gt;I killed it for four reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 1: the instrumentation tax is a project killer
&lt;/h2&gt;

&lt;p&gt;Push means every producer has to emit. In a fleet that grows most weeks, that's a standing tax on every new agent, every new cron line, and — the one that actually killed the design — every third-party MCP server I didn't write. You cannot add an &lt;code&gt;emit()&lt;/code&gt; call to a server someone else built. So the moment I add a component and forget to instrument it, or simply can't, it goes invisible in the inbox.&lt;/p&gt;

&lt;p&gt;That's the trap: the components you least control — third-party tools, things that crash early — are exactly the ones an event bus can't see. Push optimizes for the easy case (code you own) and silently fails the hard case (code you don't). An observability layer whose blind spots grow with the system is worse than no observability layer at all, because it still &lt;em&gt;looks&lt;/em&gt; authoritative while quietly lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 2: state is truth, events are rumors
&lt;/h2&gt;

&lt;p&gt;An event is a claim that depends on the claimant surviving long enough to make it. If an agent crashes before it emits &lt;code&gt;done&lt;/code&gt;, an event-based monitor shows nothing — the failure is invisible. State is the evidence left behind regardless of whether anyone remembered to report it: a stale output file, a log that stopped growing, a health check that fails, a process that just isn't there anymore.&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;# pull: derive status from evidence the component already leaves behind
&lt;/span&gt;&lt;span class="n"&gt;status&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;alive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nf"&gt;process_is_running&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;               &lt;span class="c1"&gt;# ps / launchctl print
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fresh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nf"&gt;newest_output_mtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# &amp;lt;project&amp;gt;/report/ glob
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healthy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;health_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dependency&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;               &lt;span class="c1"&gt;# poll endpoint
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# no emit() anywhere — a component that never reports is still seen
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;State is truth, events are rumors. An event only exists if something remembered to send it; state is evidence left behind regardless. A pull monitor reads that evidence and reflects a crash without needing the agent's cooperation — which is what makes it self-healing, converging on reality every cycle, while push is only ever as honest as its least-reliable emitter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reason 3: orchestration schizophrenia
&lt;/h2&gt;

&lt;p&gt;A closed-loop bus, where "close this item" emits an event that advances the next step, quietly turns the inbox into a workflow engine. I already run an orchestration hub that decomposes goals into steps. Building a second one inside the monitor duplicates that responsibility and doubles the debugging surface — now a stuck task could be the hub's fault or the inbox's, and I have to check both. A monitor should report state, not drive it. Keeping those two jobs in two separate systems is what keeps either one debuggable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 4: the bus itself becomes an unreviewed dump
&lt;/h2&gt;

&lt;p&gt;An append-only event log isn't free infrastructure. It accrues schema drift — the shape of &lt;code&gt;output.created&lt;/code&gt; changes and old readers break — plus duplicate events, events nobody ever closes, and unbounded growth that eventually demands compaction. I'd be adding a database with none of a database's guarantees, and it would need its own monitoring just to know if &lt;em&gt;it&lt;/em&gt; was healthy. Pull doesn't produce that artifact: there's nothing to compact, because there's nothing stored beyond the state that already exists on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pull looks like instead
&lt;/h2&gt;

&lt;p&gt;The inbox ends up as a &lt;strong&gt;computed view&lt;/strong&gt; over state that already exists — no new store, no &lt;code&gt;emit()&lt;/code&gt; calls anywhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attention type&lt;/th&gt;
&lt;th&gt;Derived from (pull)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New / unread output&lt;/td&gt;
&lt;td&gt;per-job output glob mtime vs. a read-timestamp record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending decision&lt;/td&gt;
&lt;td&gt;existing on-disk sources — an attention scan's output, an undecided ledger entry, an expired evaluation date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broken dependency&lt;/td&gt;
&lt;td&gt;health-check failure, propagated to anything that declares a dependency on it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Priority is deterministic, not a model's guess: &lt;strong&gt;BLOCKED → STALE → NEW&lt;/strong&gt;, where each is a hard fact — a failed health check, a file newer than its read-timestamp, a schedule past due. An LLM-generated priority number would be unfalsifiable and would drift over time; a deterministic trigger is reproducible and debuggable. I keep the model out of the ranking entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freshness is "unread," not "old"
&lt;/h2&gt;

&lt;p&gt;Pull also fixed a metric I had wrong. My first instinct for "freshness" was elapsed time — this ran three days ago, so it's stale. But age isn't actually the problem; &lt;em&gt;unread output&lt;/em&gt; is. A report that finished an hour ago and that I haven't opened yet demands more attention than one from last week that I already read.&lt;/p&gt;

&lt;p&gt;So freshness is computed as a join: does an output exist whose mtime is newer than the last time I opened it? Clicking a card records a read-timestamp; unread items rise to the top, read ones sink. This join is cheap only because the design already scans state for everything else — freshness falls out of the same glob. In a push system it would have been yet another event to emit and reconcile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary that keeps it honest
&lt;/h2&gt;

&lt;p&gt;Choosing pull forces a discipline I've come to think is the actual point: the monitor must never mutate fleet state. "Closing" an inbox item means &lt;em&gt;acknowledge, and deep-link to the real place the work gets closed&lt;/em&gt; — it does not reach in and change a job, a ledger, or an agent's state directly. The moment a monitor starts writing back, it's an orchestrator again, and reasons 1 through 3 all come back. Read the world, link to the controls, never become the controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  When push is actually right
&lt;/h2&gt;

&lt;p&gt;None of this means event buses are a bad idea in general — it means they fit a different shape of problem. If you own every producer, can instrument all of them, and need high-throughput, low-latency fan-out, push is the right tool for that job. The pull argument wins specifically for a small, heterogeneous, high-churn fleet with components you don't fully control, where the dominant cost is instrumentation and the failure that hurts most is the silent one. The question isn't "push or pull" in the abstract — it's which cost is fatal for your system: throughput, or blind spots.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>architecture</category>
      <category>mcp</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>When a Site Blocks Your Scraper, Read the Browser Tab You Already Have Open</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sun, 12 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/when-a-site-blocks-your-scraper-read-the-browser-tab-you-already-have-open-46j7</link>
      <guid>https://dev.to/hexisteme/when-a-site-blocks-your-scraper-read-the-browser-tab-you-already-have-open-46j7</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/scrape-by-reading-your-open-browser-tab.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I needed live listing prices from a property portal for a personal buy-decision tool. The portal does everything a site does to stop a scraper: it disallows the paths in &lt;code&gt;robots.txt&lt;/code&gt;, it fingerprints headless clients, it hides behind a login, and the prices don't even exist in the fetched HTML — they're painted in by JavaScript after the page loads. I spent an afternoon losing to all four defenses. Then I stopped fighting them, because the data was already sitting on my screen: I had the page open in Chrome.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For a personal tool, the move past robots.txt, bot detection, login walls, and JS-rendered single-page apps is not a better headless scraper — it's a ~40-line AppleScript bridge that reads the DOM out of the authenticated Chrome tab you &lt;em&gt;already have open&lt;/em&gt;. You inherit the human's session and the fully-rendered content, and you add zero HTTP requests, so there is no scraper for the site to detect. It sidesteps all four walls at once because a human already solved the hard parts. It is also strictly a solo, read-only, human-scale technique — and that boundary is the point, not a limitation to route around.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The wall: the data is behind everything that stops a scraper
&lt;/h2&gt;

&lt;p&gt;Every defense a site can raise was stacked on this one. A plain HTTP fetch of the listing URL got redirected to a login page. Sending cookies got past that, until the anti-bot layer noticed a client with no browser fingerprint and started serving challenges. And even a "successful" fetch was useless, because the response was an almost-empty HTML shell — the actual numbers are rendered client-side by the app's JavaScript, so the price I wanted simply wasn't in the bytes I'd downloaded. Each of these is solvable on its own with enough effort (headless browser, session replay, a rendering engine), and stacking all the solutions together is how a weekend project becomes an unmaintained pile of anti-detection hacks that breaks every time the site ships.&lt;/p&gt;
&lt;p&gt;The tell that I was solving the wrong problem: I was reconstructing, badly, a thing that already existed and worked perfectly. A logged-in browser session that renders the page and shows me the price. I had one open in front of me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The move: you already have the page open
&lt;/h2&gt;

&lt;p&gt;The reframe is small and it changes everything. Don't fetch the page — &lt;strong&gt;read the page you already fetched by opening it&lt;/strong&gt;. When the portal is open in a Chrome tab, that tab has already passed the login, already survived the bot check (it's a real browser doing real navigation), and already run the JavaScript that paints the prices into the DOM. The numbers are right there in &lt;code&gt;document.body.innerText&lt;/code&gt;. The only thing missing is a way to reach into that tab from a script.&lt;/p&gt;
&lt;p&gt;On macOS, that way is Apple Events. Chrome can evaluate JavaScript inside any open tab on request (a one-time setting, &lt;em&gt;View → Developer → Allow JavaScript from Apple Events&lt;/em&gt;), and a JXA script — JavaScript for Automation, run via &lt;code&gt;osascript&lt;/code&gt; — can drive it. The whole bridge is about forty lines: enumerate the windows and tabs, find the first tab whose URL contains a substring you pass in, and evaluate an expression inside it.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// read-tab.js  —  osascript -l JavaScript read-tab.js "new.land.naver.com" "document.body.innerText"&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jsExpr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;document.body.innerText&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Chrome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Google Chrome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;Chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no open tab matching: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Evaluate inside the live, authenticated, already-rendered tab.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(function(){try{const v=(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;jsExpr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;);&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;return typeof v===&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;?v:JSON.stringify(v);}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;catch(e){return &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;JS_ERROR: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;+(e&amp;amp;&amp;amp;e.message||e);}})()&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;javascript&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wrapped&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire mechanism. Point it at a URL substring, hand it any expression, and it returns the result as text. Pass &lt;code&gt;document.body.innerText&lt;/code&gt; for the rendered page, a specific &lt;code&gt;querySelectorAll(...)&lt;/code&gt; to pull just the fields you want, or &lt;code&gt;document.documentElement.outerHTML&lt;/code&gt; to get the post-render HTML for a downstream parser. From a Python collector it's one &lt;code&gt;subprocess&lt;/code&gt; call; the deterministic pipeline downstream never knows the data came from a browser tab instead of an API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it sidesteps every wall at once
&lt;/h2&gt;

&lt;p&gt;The reason this feels like cheating is that it doesn't &lt;em&gt;defeat&lt;/em&gt; any of the defenses — it makes all of them irrelevant, because the human already dealt with each one before the script ever runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wall&lt;/th&gt;
&lt;th&gt;Why it's already gone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login / session&lt;/td&gt;
&lt;td&gt;You read the tab under the user's authenticated session — no credentials in your code, nothing to store or refresh.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS-rendered SPA&lt;/td&gt;
&lt;td&gt;You read the DOM &lt;em&gt;after&lt;/em&gt; the app's JavaScript ran, so you see the painted numbers, not the empty shell a fetch returns.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot detection&lt;/td&gt;
&lt;td&gt;You issue no HTTP request at all. There is no headless client to fingerprint; the traffic already happened, made by a real browser a human drove.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;robots.txt&lt;/code&gt; / rate limits&lt;/td&gt;
&lt;td&gt;You aren't crawling. You read one page the user chose to open, at the rate a person clicks — the politest possible traffic profile.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those problems is hard to solve in a scraper and free to inherit from a browser. That's the whole trade: you give up automation-at-scale and get correctness-without-a-fight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest edges
&lt;/h2&gt;

&lt;p&gt;This is a sharp tool with a narrow blade, and pretending otherwise is how you get burned.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reading is reliable; writing is a different, harder problem.&lt;/strong&gt; It's tempting to also fill search boxes or click through pagination the same way. Don't assume it'll work: Apple Events JavaScript runs in an &lt;em&gt;isolated world&lt;/em&gt;, and modern frameworks override the native &lt;code&gt;value&lt;/code&gt; setter, so a plain &lt;code&gt;el.value = "..."&lt;/code&gt; is silently ignored and no &lt;code&gt;input&lt;/code&gt;/&lt;code&gt;change&lt;/code&gt; event fires — the app never sees your input. Writing has to go through the native value setter plus manually dispatched events, and it's fragile enough that I keep the reader strictly read-only and treat interaction as a separate concern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is single-user, single-machine, by design.&lt;/strong&gt; This works because &lt;em&gt;a person&lt;/em&gt; has the page open on &lt;em&gt;their&lt;/em&gt; desktop. It does not run in the cloud, does not scale, and is not a commercial scraper. If you need server-side or high-volume collection, this isn't your tool — and you should be honoring the site's terms and rate limits, not looking for a bypass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's coupled to the browser being open and permissioned.&lt;/strong&gt; The one-time Apple Events permission, the specific browser, the tab actually being open — these are real dependencies. The script fails loudly ("no open tab matching…") when they're not met, which is the right failure: it tells you to go open the page, not silently return stale or empty data.&lt;/li&gt;
&lt;/ul&gt;

## When to reach for it

&lt;p&gt;The fit is narrow and clear: a &lt;strong&gt;personal, low-volume tool&lt;/strong&gt; that needs data from a site that fights scrapers, where &lt;em&gt;you are already an authenticated human looking at the page&lt;/em&gt;. Live prices for your own buy decision, a number off a dashboard you have access to, a value from a portal you log into anyway. In that slot it's unbeatable, because you're not extracting anything you couldn't already see — you're just saving yourself the copy-paste. Outside that slot — scale, headless, other people's data, evading a wall you're not entitled to be past — it's the wrong tool, and the boundary isn't a limitation to engineer around. It's what keeps the technique honest: it only ever reads a page a person chose to open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>javascript</category>
      <category>sideprojects</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>The Silent 10 Tax: How a Nondeterministic System Prompt Voids Your LLM Prompt Cache</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 11 Jul 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/the-silent-10x-tax-how-a-nondeterministic-system-prompt-voids-your-llm-prompt-cache-39j5</link>
      <guid>https://dev.to/hexisteme/the-silent-10x-tax-how-a-nondeterministic-system-prompt-voids-your-llm-prompt-cache-39j5</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/nondeterministic-system-prompt-voids-llm-cache.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Prompt caching is the largest single token lever for any long-lived LLM session — a cached token is served at roughly a tenth of its normal price. It is also one of the easiest things to break by accident, because when it breaks there is no error. One timestamp in the system prompt, one unsorted JSON key, and your cache-read rate quietly goes to zero while the bill goes up tenfold. This is how the failure works, and how to make it loud.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A prompt cache is a &lt;strong&gt;prefix match&lt;/strong&gt;: the cache key is the exact bytes of the rendered prompt up to a breakpoint, and a single byte change anywhere in the prefix invalidates everything after it. So anything volatile near the front of the prompt — a &lt;code&gt;datetime.now()&lt;/code&gt; in the system header, a per-request UUID, a dict serialized without sorted keys, a tool list that reorders — silently defeats caching for the entire request. The cost is real (a miss is ~10× a hit on the cached span) and invisible (no exception, no warning). The only signal is one field in the usage object, and the only durable fix is to assert on it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How prefix caching actually works
&lt;/h2&gt;

&lt;p&gt;The mental model that prevents every bug below is one sentence: &lt;strong&gt;the cache key is the bytes of the rendered prompt, matched as a prefix.&lt;/strong&gt; The provider renders your request in a fixed order — tools, then the system prompt, then the messages — hashes the bytes up to each cache breakpoint, and looks for a prior entry with the same prefix. If it finds one, the shared span is a cache &lt;em&gt;read&lt;/em&gt;; the rest is processed fresh. If the very first byte differs, nothing matches and the whole thing is processed at full price.&lt;/p&gt;
&lt;p&gt;Three properties follow, and all three are load-bearing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefix, not fuzzy match.&lt;/strong&gt; There is no "close enough." Byte &lt;em&gt;N&lt;/em&gt; changing invalidates the cache for every breakpoint at position ≥ &lt;em&gt;N&lt;/em&gt;. Stable content has to physically come &lt;em&gt;before&lt;/em&gt; volatile content in the rendered prompt, or the volatile content poisons everything after it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model-scoped.&lt;/strong&gt; Caches are keyed per model. Switching the model mid-session — even to a cheaper one for a sub-task — starts from a cold cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short-lived.&lt;/strong&gt; On Anthropic's API the default cache entry lives ~5 minutes (a 1-hour tier exists at a higher write premium). A gap longer than the TTL means the next request pays to write the cache again.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The economics are what make this worth caring about. A cache &lt;strong&gt;read&lt;/strong&gt; costs about &lt;strong&gt;0.1×&lt;/strong&gt; the base input price; a cache &lt;strong&gt;write&lt;/strong&gt; costs about &lt;strong&gt;1.25×&lt;/strong&gt; for the 5-minute TTL. So the break-even is fast — two requests sharing a prefix already come out ahead — and the downside of a silent miss is steep: every token you &lt;em&gt;could&lt;/em&gt; have read at one-tenth price is instead reprocessed at full price. That is the 10× in the title, and on a long agent loop that resends a large fixed preamble every turn, it is most of the bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent failure: &lt;code&gt;cache_read&lt;/code&gt; stays zero and nothing tells you
&lt;/h2&gt;

&lt;p&gt;Here is what makes this class of bug nasty. A broken cache is &lt;em&gt;not an error&lt;/em&gt;. Your requests succeed, your outputs are correct, your latency is a little worse and your cost is a lot worse, and nothing in the response says "you just paid full price for 40,000 tokens you had cached thirty seconds ago." The system is behaving exactly as designed; it simply never found a matching prefix.&lt;/p&gt;
&lt;p&gt;The only signal is the usage object. Every response reports three numbers you have to actually look at:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cache_read_input_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Served from cache — you paid ~0.1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cache_creation_input_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Written to cache — you paid the ~1.25× write premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;input_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reprocessed at full price — &lt;strong&gt;not cached&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If &lt;code&gt;cache_read_input_tokens&lt;/code&gt; is zero across repeated requests that should share a prefix, a silent invalidator is at work.&lt;/strong&gt; That single field is the whole diagnosis. When it's zero and you expected a hit, the move is mechanical: capture the fully rendered prompt bytes from two consecutive requests and diff them. The byte that changed is your invalidator, and it is almost always one of a small, known set.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The invalidators, in order of how often they bite
&lt;/h2&gt;

&lt;p&gt;Every one of these changes the prefix bytes without changing the logical prompt — which is exactly why they're easy to write and hard to notice.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A timestamp or UUID in the system prompt.&lt;/strong&gt; The classic. &lt;code&gt;f"Current time: {datetime.now()}"&lt;/code&gt; or a per-request trace ID interpolated into the system header changes the prefix on &lt;em&gt;every single request&lt;/em&gt;, so the cache is never read even once. Anything that must be dynamic belongs after the last breakpoint, in the message list — a fact injected at turn 5 invalidates nothing before turn 5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-deterministic serialization.&lt;/strong&gt; Building any part of the prefix with &lt;code&gt;json.dumps(d)&lt;/code&gt; without &lt;code&gt;sort_keys=True&lt;/code&gt;, or by iterating a &lt;code&gt;set&lt;/code&gt;, can emit different bytes run to run for identical content. Same logical prompt, different key order, cache miss. Sort your keys and order your collections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-set churn.&lt;/strong&gt; Tools render at position zero, ahead of everything. Adding, removing, or reordering a tool changes the very front of the prefix and invalidates the entire cache below it — across the whole session, and across users if the tool set is built per-user. Freeze the tool list and sort it deterministically; for genuinely dynamic tools, use a discovery mechanism that &lt;em&gt;appends&lt;/em&gt; schemas rather than swapping the base set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user or per-session interpolation into the system prompt.&lt;/strong&gt; Splicing a user ID, name, or session key into the system header gives every user a distinct prefix and kills all cross-request and cross-user sharing. Move it into the messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conditional system sections.&lt;/strong&gt; &lt;code&gt;if flag: system += "..."&lt;/code&gt; means every combination of flags is a different prefix. Each variant caches separately and shares nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mid-session edits and model switches.&lt;/strong&gt; Editing the top-level system prompt partway through a conversation re-processes the whole history uncached; switching models throws the cache away entirely (it's model-scoped). If you need to inject an operator instruction mid-run, append it as a message rather than editing the frozen system prompt.&lt;/li&gt;
&lt;/ul&gt;

## Why it hurts most exactly where you'd want caching

&lt;p&gt;The cruel part is that the failure concentrates in precisely the workloads caching is &lt;em&gt;for&lt;/em&gt;. A one-shot classification call barely cares. A long-running agent — a fleet of workers, a multi-step DAG, an orchestration loop that resends a large fixed system prompt and tool set on every one of hundreds of turns — is entirely built on the assumption that the big fixed prefix is nearly free after the first write. When a worker reassembles its system prompt on each call and one field in that assembly is nondeterministic, that assumption silently inverts: the largest, most-reused span of the prompt becomes the most-repaid. The bigger and more disciplined your prompt architecture, the more a single stray byte costs you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit: make the cache assert itself
&lt;/h2&gt;

&lt;p&gt;Because the failure is silent, the only real defense is to stop trusting and start measuring. The audit is short:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classify every input by how often it changes.&lt;/strong&gt; Never → put it early, before any breakpoint. Per-session → after the global prefix. Per-turn → at the very end. Per-&lt;em&gt;request&lt;/em&gt; (timestamps, IDs) → eliminate it from the prefix or move it dead last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that rendered order matches stability order.&lt;/strong&gt; Stable bytes must physically precede volatile bytes. A timestamp in the system header defeats every breakpoint after it no matter how many &lt;code&gt;cache_control&lt;/code&gt; markers you sprinkle downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze the load-bearing pieces.&lt;/strong&gt; System prompt frozen, tools deterministically ordered, model fixed for the session, JSON serialized with sorted keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then assert.&lt;/strong&gt; After the first request warms the cache, the code should check &lt;code&gt;usage.cache_read_input_tokens &amp;gt; 0&lt;/code&gt; on the second — and fail loudly if it isn't. A silent cache miss caught by an assertion in development is a non-event; the same miss shipped to a long-running fleet is a standing tax you won't see until you read the bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It's the same discipline as any other cache: know your key, keep it stable, and verify your hit rate instead of assuming it. The only twist with prompt caching is that the key is the entire rendered prefix, so "keep it stable" reaches all the way back into how you build the system prompt — and the penalty for a stray byte is measured in tens of percent of your token bill, paid quietly, forever, until someone looks at the one field that would have told them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
    </item>
    <item>
      <title>A Fair Coin Isn't Enough: When a Perfectly Randomized Experiment Is Impossible to Analyze</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 10 Jul 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/a-fair-coin-isnt-enough-when-a-perfectly-randomized-experiment-is-impossible-to-analyze-29cb</link>
      <guid>https://dev.to/hexisteme/a-fair-coin-isnt-enough-when-a-perfectly-randomized-experiment-is-impossible-to-analyze-29cb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/randomized-experiment-unanalyzable-missing-join.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We built a randomized experiment to earn the right to say one word — &lt;em&gt;because&lt;/em&gt;. The random assignment was cryptographically fair; we verified it. Weeks in, an audit found that the experiment could not be analyzed at all, and never could have been. The coin was flawless. The &lt;em&gt;join&lt;/em&gt; from the coin to the thing it decided did not exist in the data.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To make a randomized experiment analyzable you need three things that have nothing to do with the randomizer: a &lt;strong&gt;join&lt;/strong&gt; that links each draw to the unit it was applied to and that unit's outcome, a &lt;strong&gt;guard&lt;/strong&gt; that makes selective re-drawing impossible or at least detectable, and a &lt;strong&gt;power calculation&lt;/strong&gt; done before you start so you know the sample can answer the question. We had a provably fair coin and none of the three. This is what each one is, why its absence is fatal, and what you actually have to record at the instant of assignment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why randomize at all: to earn one causal word
&lt;/h2&gt;

&lt;p&gt;Most of what you can measure about a running system is observational — patterns under whatever policy happens to be in force. Observational data can generate hypotheses, but it cannot, by construction, tell you that a change &lt;em&gt;caused&lt;/em&gt; an outcome, because the thing you changed is tangled up with everything else that moved at the same time. There is exactly one clean escape: assign the treatment &lt;em&gt;at random&lt;/em&gt;. Randomization is what severs the treatment from all the confounders and lets you say the forbidden word — &lt;em&gt;because&lt;/em&gt; — about the difference between arms.&lt;/p&gt;
&lt;p&gt;So for one specific class of decisions we did the disciplined thing. Instead of always routing a task to the default handler, a dispatcher flipped a fair coin and sent it to arm A or arm B. That single randomized track was meant to be the one place in the whole system where a causal claim would be defensible. Everything hinged on it being done right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coin was provably fair — and that was the easy 10%
&lt;/h2&gt;

&lt;p&gt;The randomizer used a cryptographically seeded source (&lt;code&gt;SystemRandom().choice&lt;/code&gt;, backed by the OS entropy pool), so the draw was an honest, independent p=0.5 flip. When the observed split came in lopsided — roughly four to one — the instinct was to suspect a broken coin. That instinct was wrong: with that few draws, a two-sided binomial test puts a 4:1 split at &lt;strong&gt;P ≈ 0.375&lt;/strong&gt;, nowhere near anything you would call biased. The coin was fine.&lt;/p&gt;
&lt;p&gt;Here is the trap. Verifying the coin &lt;em&gt;feels&lt;/em&gt; like verifying the experiment. It is vivid, it is mathematical, it produces a satisfying green check. And it is maybe ten percent of what makes a randomized experiment work. The other ninety percent is bookkeeping so boring nobody writes a test for it — and that is exactly where this one had already failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fatal bug: nothing joined the draw to the task it dispatched
&lt;/h2&gt;

&lt;p&gt;To analyze a randomized experiment you compute an &lt;strong&gt;intention-to-treat&lt;/strong&gt; comparison: for every unit, what arm was it &lt;em&gt;assigned&lt;/em&gt;, and what &lt;em&gt;outcome&lt;/em&gt; did that unit get. That requires a join key — a stable identifier stamped on the unit at the moment of assignment and carried through to wherever the outcome is recorded. It is the spine of the entire analysis.&lt;/p&gt;
&lt;p&gt;Our dispatcher was a function that took &lt;em&gt;no arguments&lt;/em&gt;, returned an arm, and appended the draw to a log. The log recorded that a draw happened and which arm won. It did not record &lt;em&gt;which task&lt;/em&gt; the draw was for. Nothing downstream ever read the dispatch log to tag the resulting work. In other words, the assignment and the outcome lived in two separate piles with &lt;strong&gt;no key joining them&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You cannot backfill this.&lt;/strong&gt; The pairing between a draw and the task it dispatched is only knowable at the instant the dispatcher hands the task off. Once that moment passes without a shared id being written down, the link is gone — not hard to reconstruct, &lt;em&gt;gone&lt;/em&gt;. Afterward you have N draws and M outcomes and no way to say which draw produced which outcome. You can count each pile. You cannot pair them. The intention-to-treat comparison — the only reason the experiment existed — is uncomputable by any code you could ever write against that data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Three ways the ledger couldn't defend itself
&lt;/h2&gt;

&lt;p&gt;The missing join was the headline, but the same audit found three smaller failures, each of which independently prevents an experiment from being trustworthy even by an honest operator.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No guard against selective re-randomization.&lt;/strong&gt; Nothing in the pipeline made "one unit, one assignment, recorded once" an enforced invariant, and nothing would have &lt;em&gt;detected&lt;/em&gt; a second draw for the same unit. The danger here is not that anyone cheated — it is that the experiment has no mechanism to prove it &lt;em&gt;couldn't&lt;/em&gt; have. An experiment that cannot rule out selective re-draws cannot defend its own result against that exact objection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test draws share a pile with real draws.&lt;/strong&gt; Draws made while building and debugging the harness landed in the same log as production draws, separated only by a note in a design doc — not a flag in the data. A cluster of near-simultaneous draws (42 milliseconds apart, machine-fast, obviously not real dispatches) sat unclassified in the log, occupying exactly the position where genuine data would be counted. The rule "developer draws don't count" is worthless if it lives in prose instead of an &lt;code&gt;is_test&lt;/code&gt; column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The falsifier that can't fire.&lt;/strong&gt; The experiment was pre-registered with a kill switch: "if assignment/outcome mismatch exceeds 20%, declare the design a failure and stop." But mismatch is a quantity you compute &lt;em&gt;from the join&lt;/em&gt; — and there is no join. So the falsifier references a number that cannot be measured. It can never fire. A stopping rule you cannot compute is not a stopping rule; it is a comforting sentence in a spec.&lt;/li&gt;
&lt;/ul&gt;

## Even fixed, it was underpowered — and nobody had checked

&lt;p&gt;Suppose we fix all of the above tomorrow. There is a separate, earlier failure hiding underneath: the experiment was never &lt;strong&gt;powered&lt;/strong&gt;. No minimum detectable effect was computed before it started. When we projected the effective sample size forward to the pre-registered deadline, it came to roughly &lt;strong&gt;14–19 usable units against a target of 40&lt;/strong&gt; — under half. The experiment was on track to end inconclusive no matter how clean the bookkeeping was.&lt;/p&gt;
&lt;p&gt;This is the quietest and most common way experiments waste themselves. Without a pre-planned MDE, an inconclusive result is &lt;em&gt;ambiguous&lt;/em&gt;: you cannot distinguish "the two arms are genuinely equivalent" from "we never had enough units to see a difference." Those are opposite conclusions that produce identical-looking null results. The power calculation is what forces you to find out, &lt;em&gt;before&lt;/em&gt; you spend the sample, whether the question is even answerable at the scale you can afford.&lt;/p&gt;

&lt;h2&gt;
  
  
  What instrumenting a randomized experiment actually requires
&lt;/h2&gt;

&lt;p&gt;The transferable lesson is that randomization is the part that comes free from a library, and everything that makes the randomization &lt;em&gt;usable&lt;/em&gt; is manual work you have to design in from the first line. Concretely, before the first real draw:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stamp a joined record at assignment time.&lt;/strong&gt; One row per draw carrying a stable &lt;code&gt;unit_id&lt;/code&gt;, the assigned &lt;code&gt;arm&lt;/code&gt;, an &lt;code&gt;is_test&lt;/code&gt; flag, and a &lt;code&gt;timestamp&lt;/code&gt;. The &lt;code&gt;unit_id&lt;/code&gt; is non-negotiable — it is the key the entire analysis hangs from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carry the unit id to the outcome.&lt;/strong&gt; The place where you measure the result must write down the same &lt;code&gt;unit_id&lt;/code&gt;. If the outcome side doesn't know the key, the assignment side stamping it was pointless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make re-draws impossible or loud.&lt;/strong&gt; Enforce one-assignment-per-unit, or at minimum emit a warning when the same unit is drawn twice within a short window, so selective re-randomization can't happen silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate test from production in the data, not in prose.&lt;/strong&gt; The &lt;code&gt;is_test&lt;/code&gt; flag, set at draw time, is the only thing that reliably keeps development noise out of your effect estimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute the minimum detectable effect first.&lt;/strong&gt; Decide, before you begin, the smallest effect worth detecting and whether your reachable sample can detect it. If it can't, you don't have an experiment yet — you have a plan to collect an ambiguous null.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Randomizing an assignment is a one-line call to a good RNG. Turning that into an experiment you can actually analyze is a join, a guard, a flag, and an arithmetic check you do up front. We had the one-liner and skipped the four boring things, and the result was a beautifully fair coin deciding an experiment that, on the day we looked, could not answer its own question. The coin was never the hard part.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>data</category>
      <category>dataengineering</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Model or Method? Building a Deterministic Lab to Measure an AI Agent Fleet's Own Behavior</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 04 Jul 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/model-or-method-building-a-deterministic-lab-to-measure-an-ai-agent-fleets-own-behavior-47im</link>
      <guid>https://dev.to/hexisteme/model-or-method-building-a-deterministic-lab-to-measure-an-ai-agent-fleets-own-behavior-47im</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/pamsl-model-or-method-agent-behavior-science-lab.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A one-time forensic on our agent fleet concluded that "flagship-grade" quality lived in the &lt;em&gt;method&lt;/em&gt; — orthogonal verification, ground-truth-first, closing the feedback loop — not in any one model's weights. That's a satisfying conclusion, and completely unfalsifiable as stated. A hand-audit of a few dozen sessions doesn't scale, doesn't update, and quietly launders the auditor's priors into the result. So we built the thing that would let the claim be &lt;em&gt;wrong&lt;/em&gt;: a small, deterministic lab that measures the fleet's own behavior, session after session, with no LLM in the scoring path.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The forensic said method beats model. To test that ongoingly you need two columns most transcript tooling never captures: which &lt;em&gt;method&lt;/em&gt; a session used, and what &lt;em&gt;outcome&lt;/em&gt; it got. We built a stdlib-only SQLite lab that mines the fleet's own Claude Code transcripts, reassembles logical sessions (94% of the raw files are sidechain fragments — a file is not a session), and attributes every behavior to a &lt;code&gt;(model, epoch)&lt;/code&gt; tuple instead of a thread-level majority. It refuses causal language everywhere except one randomized-dispatch track. The newest piece is a &lt;em&gt;method fingerprint&lt;/em&gt;: per-epoch counts of which disciplines a session actually invoked — a council, a cross-family verifier, symbolic-math and theorem-prover engines, skills. And the first real lesson wasn't in the data. It was that the "obvious" field for skills — a top-level &lt;code&gt;slug&lt;/code&gt; appearing 21,000+ times — turned out to be a session codename, not a skill. Trusting our own field catalog instead of the raw values would have ingested pure noise as signal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The trap: "quality is the method" is unfalsifiable until you measure both axes
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable thing about concluding that quality comes from method rather than model. It is almost certainly &lt;em&gt;true&lt;/em&gt;, and it is also exactly the shape of a claim that survives because nobody can check it. If a session goes well, you can always point at some discipline it followed. If it goes badly, you can always point at a discipline it skipped. The claim is a magnet for confirmation bias because the "method" is observed &lt;em&gt;after&lt;/em&gt; you already know the outcome.&lt;/p&gt;
&lt;p&gt;To make it falsifiable you need to record the method &lt;em&gt;independently of&lt;/em&gt; the outcome, and record the outcome &lt;em&gt;independently of&lt;/em&gt; your feelings about the session. That is two columns: a method label and an outcome measure, captured deterministically, per session, before anyone forms an opinion. Almost no transcript tooling captures either well — it captures tokens, latency, and cost, which are the things that are easy to log and the things that least distinguish a careful session from a sloppy one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why measuring an agent fleet is harder than it looks
&lt;/h2&gt;

&lt;p&gt;Three structural facts make this genuinely hard, and each one silently corrupts the naive version of the analysis.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A file is not a session.&lt;/strong&gt; In our corpus, &lt;strong&gt;94% of the transcript files are sidechain fragments&lt;/strong&gt; — subagent and workflow spawns, not top-level sessions. If you treat one file as one unit of analysis, your "sessions" are mostly fragments of sessions, and your per-session statistics are measuring delegation shape, not work. The unit has to be a reassembled &lt;em&gt;logical session&lt;/em&gt;: a main thread plus every fragment that hangs off its UUID directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One session is not one model.&lt;/strong&gt; A single thread frequently spans two or three models (a main model delegating to cheaper subagents, or a mid-session model switch). If you attribute the whole thread's behavior to its majority model, you mis-file a large fraction of turns onto the wrong model — in our data, when a thread was mixed, a majority-vote attribution pooled the minority model's turns straight into the wrong bucket. The fix is to attribute every metric to a &lt;code&gt;(family, version, epoch)&lt;/code&gt; tuple at the level of the &lt;em&gt;emitting&lt;/em&gt; assistant turn, and to keep a &lt;code&gt;model_purity&lt;/code&gt; column so you can gate cross-model comparisons to near-pure threads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The corpus evaporates.&lt;/strong&gt; The raw transcripts auto-delete after about 30 days. That makes the least glamorous component — an incremental, delete-free snapshot job on a cron — the single most important pipeline in the whole system. If it dies, the lab silently loses its ability to ever look further back than a month, and you don't notice until you need the history.&lt;/li&gt;
&lt;/ul&gt;

## The discipline that makes it a lab and not a dashboard

&lt;p&gt;The temptation with behavioral data is to immediately declare that model A "is better than" model B because its numbers look nicer. That is not what observational transcript data can support, and pretending otherwise is how you get confident, wrong routing changes. So the lab is built around one rule: &lt;strong&gt;association-only, everywhere, except one place.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Concretely: every comparison table reports descriptive medians, IQRs, and cell counts, plus a bootstrap 95% CI — and &lt;em&gt;no p-values&lt;/em&gt; at this stage, because with this many implicit comparisons a p-value would be a license to fool yourself. Every table is forced to carry an overlap diagnostic (how many sessions actually populate each model × stratum cell), and any cell that doesn't overlap its comparison is stamped &lt;code&gt;NOT_COMPARABLE&lt;/code&gt; rather than quietly compared anyway. Causal words — "because," "improves," "outperforms" — are banned from the report generator's vocabulary. The one exception is a &lt;strong&gt;randomized-dispatch track&lt;/strong&gt;: for a specific class of delegations, the model is assigned at random, which is the only condition under which the data can actually identify a causal effect. Everything else is honestly labelled as what it is: a pattern under a fixed policy, Grade C evidence at best.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method fingerprint: what discipline did this session actually use?
&lt;/h2&gt;

&lt;p&gt;This is the piece that connects the lab back to the original "method beats model" claim. To ever correlate a discipline with an outcome, you have to record, per model-epoch, which disciplines a session reached for. Not what it &lt;em&gt;should&lt;/em&gt; have done — what it &lt;em&gt;did&lt;/em&gt;, as a deterministic count.&lt;/p&gt;
&lt;p&gt;The disciplines that matter for our "flagship-grade" hypothesis all happen to leave the same kind of trace: they are tool calls to specific verification instruments. A multi-persona &lt;em&gt;council&lt;/em&gt; for hard trade-offs. A &lt;em&gt;cross-family verifier&lt;/em&gt; that routes a claim to a different model lineage so its failure modes are uncorrelated with the author's. Symbolic-math and theorem-prover engines that turn a prose numerical claim into something that either checks or doesn't. Explicitly loaded skills. Each of these is a tool invocation the transcript already records — so the method fingerprint is just a per-epoch, per-instrument call count, stored in a normalized table so the open-ended set of instruments never becomes an ever-widening pile of columns.&lt;/p&gt;
&lt;p&gt;Across 155 sessions the fingerprint is already legible: symbolic-math and cross-family-verification calls dominate the flagship model's epochs, while the current relaunched-flagship regime has only a handful of sessions and a correspondingly thin fingerprint. That thinness is itself the honest finding — you cannot yet say anything about the newest regime, and the table says so out loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first lesson wasn't in the data — it was to distrust our own catalog
&lt;/h2&gt;

&lt;p&gt;Before any of that could work, we had to answer a boring question: where, in the raw transcript, does "which skill was invoked" actually live? An earlier structural census had catalogued a top-level field called &lt;code&gt;slug&lt;/code&gt;, appearing over 21,000 times, and labelled it the skill/command slug. Perfect — ingest that.&lt;/p&gt;
&lt;p&gt;Except the raw values were &lt;code&gt;snuggly-whistling-hamster&lt;/code&gt;, &lt;code&gt;functional-toasting-bird&lt;/code&gt;, &lt;code&gt;cozy-stargazing-meadow&lt;/code&gt;. Six distinct values across a large sample, each repeated hundreds of times. That is not a skill name. That is an auto-generated &lt;em&gt;session codename&lt;/em&gt;. The census had counted the field correctly and described it wrongly, and the description was the part we were about to build on.&lt;/p&gt;
&lt;p&gt;The actual skill signal was somewhere much less exotic: the ordinary tool-call stream the parser was &lt;em&gt;already&lt;/em&gt; reading. Skills invoked through the tool interface carry their name in the call's input; verification instruments carry theirs in the tool name itself. Using those meant zero new field dependencies, zero parser changes, and — critically — no reliance on a summary that had already been wrong once. We also looked at a richer-seeming top-level attribution field, but it was stamped on roughly three times more records than there were actual tool calls, because it also tagged the follow-up turns that merely processed a tool result. Counting it would have inflated "method usage" threefold. We left it on the floor and counted the calls themselves.&lt;/p&gt;
&lt;p&gt;The transferable lesson is not about one field. It is that &lt;strong&gt;a catalog of your data is itself data, and it can be wrong.&lt;/strong&gt; The counts were right and the meaning was wrong, which is the most dangerous combination, because the counts &lt;em&gt;look&lt;/em&gt; like verification. The only defense is to read the raw values with your own eyes before you build on the label — the same ground-truth-first discipline the lab exists to measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations (the part that keeps it a science)
&lt;/h2&gt;

&lt;p&gt;Four caveats, stated plainly because a measurement system that hides its weaknesses is just a dashboard with better PR.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Process proxies are not quality.&lt;/strong&gt; The closest thing to an outcome the lab has today is a set of proxies — a same-file re-edit rate, a validation-after-edit rate, a completion flag. The re-edit rate in particular &lt;em&gt;cannot distinguish careful iteration from thrashing rework&lt;/em&gt;, and it carries that caveat in the code, the schema, and the report. Real outcome labels are the hard, unsolved next axis; without them, "method vs outcome" is still half a sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The interesting regime is too small.&lt;/strong&gt; The current relaunched-flagship model has a single-digit session count in the comparable window. The system's response is not to shrink the error bars and pretend; it is to print &lt;code&gt;NOT_COMPARABLE&lt;/code&gt; and register a dated falsifier: if that count doesn't cross a threshold by a specific date, the associated hypothesis stays suspended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lab is in its own corpus.&lt;/strong&gt; The sessions that &lt;em&gt;built&lt;/em&gt; this lab are transcripts too, and they're in the data — and transcript-processing work has an unusually spiky tool signature. Self-measurement contamination is noted in the report's limitations and slated for its own exclusion stratum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Association is not causation, still.&lt;/strong&gt; Everything outside the randomized track is a pattern under a fixed routing policy. It can generate hypotheses. It cannot, by construction, prove one.&lt;/li&gt;
&lt;/ul&gt;

## The lab holds itself to the same standard

&lt;p&gt;The final piece of honesty is a falsifier pointed at the lab itself. It has a due date: if several weeks of these reports never once change an actual routing decision — logged in a ledger, not remembered — then the lab has failed its only job and gets cut down, regardless of how elegant the schema is. A measurement system whose measurements never change a decision is a hobby, and the way you keep it from becoming one is to write down, in advance, the observation that would prove it useless.&lt;/p&gt;
&lt;p&gt;That is the whole posture, really. The claim that started this — quality is method, not model — only becomes worth anything the moment you build the apparatus that could show it's false, capture the two axes independently, and read your own raw data before you trust your own labels. The lab isn't the answer to "model or method." It's the thing that makes the question answerable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>softwaredevelopment</category>
      <category>testing</category>
    </item>
    <item>
      <title>How to Stop AI Agent Skills, Hooks, and Cron Jobs from Silently Conflicting Over Where They Run and What Data They Trust</title>
      <dc:creator>John</dc:creator>
      <pubDate>Wed, 01 Jul 2026 00:00:08 +0000</pubDate>
      <link>https://dev.to/hexisteme/how-to-stop-ai-agent-skills-hooks-and-cron-jobs-from-silently-conflicting-over-where-they-run-2294</link>
      <guid>https://dev.to/hexisteme/how-to-stop-ai-agent-skills-hooks-and-cron-jobs-from-silently-conflicting-over-where-they-run-2294</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/harness-invariants-stop-cross-component-leaks.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Make every skill, hook, and scheduled job declare four invariants before it ships — Locality (where it can run), Source-of-truth (which facts it owns or borrows), Cross-ref (what depends on it and what it depends on), and Trigger-measurability (whether its trigger is observable at runtime or hidden in external state) — and refuse to hand off any component that leaves one undeclared, because an undeclared assumption is exactly the seam where two components silently disagree.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two separate runtime leaks surfaced in a single audit session, and both traced back to the same root cause: a component that never declared its assumptions. One read configuration from a file that had stopped being the source of truth (so it always returned a stale default); the other was a scheduled job pointed at a remote sandbox while its prompt referenced local-only paths — caught minutes before registration, where any later and it would have billed compute and produced nothing. Neither was a coding bug. Both were missing declarations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The failure mode: components that work alone but leak when combined
&lt;/h2&gt;

&lt;p&gt;When you build an AI agent system out of small parts — skills the model loads on demand, hooks that fire on lifecycle events, cron jobs and scheduled routines that run unattended, helper scripts, config profiles — each part usually gets tested in isolation. It works. You move on. The trouble is that "it works" only proves single-shot correctness; it says nothing about whether the part's &lt;em&gt;assumptions&lt;/em&gt; agree with the rest of the system.&lt;/p&gt;
&lt;p&gt;Every component carries hidden assumptions: where it runs (local machine vs. a remote sandbox), which facts it treats as authoritative, what other components it silently depends on, and what its trigger actually measures. When those assumptions go undeclared, conflicts stay invisible until they surface to the user as a flaky, hard-to-trace symptom — the kind that feels like a vicious cycle because every fix in one place re-opens a gap somewhere else. The deeper problem isn't any single line of code; it's that &lt;strong&gt;each component is operating without knowing its own assumptions&lt;/strong&gt;, so nothing can detect when two of them disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: four invariants every component must declare
&lt;/h2&gt;

&lt;p&gt;The rule is simple and enforceable: &lt;strong&gt;every new skill, hook, cron job, script, or routine declares four invariants, or it cannot be handed off.&lt;/strong&gt; An undeclared invariant means the component itself is incomplete — it is the literal root of conflicts, leaks, and automations that bill compute but return nothing.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Locality&lt;/strong&gt; — Where can this run? Declare one of &lt;code&gt;local-only&lt;/code&gt;, &lt;code&gt;repo-only&lt;/code&gt;, or &lt;code&gt;both&lt;/code&gt;. A job that runs in a remote sandbox but references local-only paths is broken before it starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source-of-truth&lt;/strong&gt; — Which facts does it own authoritatively, and when it borrows an external fact, where does that fact actually live? Declare a &lt;code&gt;sources: [...]&lt;/code&gt; list of the files/components it reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-ref&lt;/strong&gt; — What depends on this component, and what does it depend on? Declare a &lt;code&gt;consumers: [...]&lt;/code&gt; list (who applies or reads it) and a "See also" section (what it relies on).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger-measurability&lt;/strong&gt; — Can the model observe this trigger in real time within a session, or does it depend on external state (machine powered on, network reachable, a remote service up)? If it's external-state-dependent, say so explicitly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These four answer the four questions that, left unanswered, become the four ways components leak into each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two real leaks, one missing declaration
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Leak 1 — stale source-of-truth.&lt;/strong&gt; A small script reported which config profile was active by reading the &lt;code&gt;mcpServers&lt;/code&gt; key from one settings file. But the authoritative definition of those servers had migrated to a different file. The script was written when the first file was the source of truth, and never updated after the move — so it always returned the default &lt;code&gt;base&lt;/code&gt;, and the status it displayed was quietly wrong. Had a &lt;strong&gt;Source-of-truth&lt;/strong&gt; invariant been declared ("this fact lives in file B, not file A"), the mismatch would have been visible the day the truth moved.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Leak 2 — locality mismatch.&lt;/strong&gt; A scheduled routine was registered to run in a remote sandbox, but its prompt referenced local-only resources (paths that exist only on the developer's machine). This was caught in the final seconds before registration. Any later and the schedule would have fired on the remote, found nothing, and produced billed-but-empty runs on a recurring cadence. A declared &lt;strong&gt;Locality&lt;/strong&gt; invariant ("this runs remote → it may only reference remote-visible resources") would have flagged it immediately.&lt;/p&gt;
&lt;p&gt;The two leaks looked unrelated — one a config-reading bug, one a scheduling mistake — but they share a single cause: a component that never declared its assumptions. That they happened in the same session is the tell that this is a structural gap, not a one-off slip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the declarations live, by component type
&lt;/h2&gt;

&lt;p&gt;Keep the declaration cost to roughly one line per invariant — the weight of declaring is far below the cost of verifying a conflict after the fact. Concretely:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# For a reusable decision record / doc with frontmatter:
---
id: NOTE-XXX
locality: local-only        # or repo-only / both
sources: ["path/to/file-that-owns-the-fact"]
consumers: ["who-applies-this", "downstream-automation"]
---&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a &lt;strong&gt;skill or command&lt;/strong&gt;, put the same four in the markdown header or description — minimally a one-liner like &lt;code&gt;(locality: local-only — depends on ~/config)&lt;/code&gt; plus a "See also" listing the RDUs/skills/commands it relies on. For a &lt;strong&gt;hook&lt;/strong&gt; defined in JSON (where comments aren't allowed), register the invariants in the hook's companion doc and list the settings file in &lt;code&gt;sources&lt;/code&gt;. For a &lt;strong&gt;cron job or routine&lt;/strong&gt;, gate registration behind five questions answered out loud first: (1) Locality — does it run local or remote, and do its referenced resources exist there? (2) Sources — what's the source-of-truth for every fact the prompt cites? (3) Cross-ref — where does the output flow (a user report, a file update, a record evolution)? (4) Trigger-measurability — does the cron time line up with resource availability (machine on, service reachable)? (5) Reversibility — how do you turn it off if it's wrong (&lt;code&gt;launchctl unload&lt;/code&gt; / disable / delete)? If even one answer is fuzzy, hold registration until it's clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backfilling existing components and keeping it honest
&lt;/h2&gt;

&lt;p&gt;You don't have to retrofit everything at once. Prioritize by blast radius. System entry points (the scripts that classify profiles or route config) come first — they're the most dangerous because everything downstream trusts them. Next, hooks that fire on every prompt, since a wrong assumption there taxes every interaction. Then your highest-frequency skills. Everything else can be backfilled slowly on a periodic audit cadence.&lt;/p&gt;
&lt;p&gt;Bake the check into that cadence: scan for decision records whose frontmatter is missing &lt;code&gt;locality&lt;/code&gt; (backfill candidates), skills whose headers don't declare invariants (backfill candidates), and any routine/cron/hook unchanged for 30+ days (re-verify its invariants, because the world it assumed may have moved underneath it — exactly how Leak 1 happened). Declaring an invariant isn't bureaucracy; it makes each component self-document its assumptions so that the next person to touch the system — including a future version of you, or your own agent — can detect a conflict on sight instead of debugging it in production.&lt;/p&gt;

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

&lt;p&gt;This is a discipline, not a compiler. Nothing mechanically enforces the four invariants unless you build that enforcement — a frontmatter linter, a registration gate, a pre-commit check. Declarations can also drift: a component can declare &lt;code&gt;sources: [file A]&lt;/code&gt; truthfully on Monday and become a lie on Friday when the source-of-truth moves, which is precisely why the 30-day re-verification step exists rather than a one-time declaration. The invariants also can't catch a conflict between two components that each declare correctly but make incompatible &lt;em&gt;assumptions about each other's behavior&lt;/em&gt; — declaration surfaces the seam, it doesn't reconcile semantics. And there's a real failure mode of over-declaring: if every trivial helper grows a four-field header, people stop reading them and the signal drowns. Keep declarations to one line each, reserve the full five-question gate for unattended automation (where a silent failure is most expensive), and treat the lightweight inline form as enough for in-session skills and commands.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Do Multiple Personas on One LLM Give Real Diversity, or Do You Need Different Model Families?</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 30 Jun 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/do-multiple-personas-on-one-llm-give-real-diversity-or-do-you-need-different-model-families-1l9m</link>
      <guid>https://dev.to/hexisteme/do-multiple-personas-on-one-llm-give-real-diversity-or-do-you-need-different-model-families-1l9m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/personas-vs-model-families-multi-agent-council-diversity.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Multiple personas on a single LLM do not give you real diversity — they are prompt variations of one set of weights, so they share the same blind spots, and the only durable diversity comes from different model families plus external tool verification plus an adversarial round.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In an 8-round self-audit of a persona-only council (all personas on the same model family), the measured ceiling was a track_record of 31% and internal_consistency of 65% — no amount of prompt-trailer tuning, persona-count tuning, or model-tier splitting pushed past it. Two patterns explain why and how to fix it: the Aggregator Bottleneck (a single aggregator model re-homogenizes whatever diversity its sub-agents produced) and a ΔEVD test (measure mean-pairwise-cosine-distance between answers; keep a reframing only if it adds more than +0.15, discard it as theatrical if it adds +0.05 or less).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The short answer: same weights, same blind spots
&lt;/h2&gt;

&lt;p&gt;If you spin up eight "experts" — a skeptic, an optimist, a security reviewer, a contrarian — and they are all the &lt;em&gt;same underlying model&lt;/em&gt; behind different system prompts, you have built a debate club whose members were all educated at the same school, from the same textbooks, by the same teachers. They will phrase disagreement differently, but they hallucinate the same nonexistent citations, anchor on the same wrong dates, and miss the same structural weakness in your design. Persona diversity is a presentation-layer trick; it is not an epistemic one.&lt;/p&gt;
&lt;p&gt;This matters because a council's whole value proposition is catching the error your first answer missed. If every member shares the same training data, the same RLHF shaping, and the same tokenizer, then the error your base model is prone to is an error &lt;em&gt;all&lt;/em&gt; members are prone to. You get a chorus, not a cross-check. The fix is not more personas — it is genuinely different sources of judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measured ceiling from an 8-round self-audit
&lt;/h2&gt;

&lt;p&gt;This isn't a vibe. Over eight rounds of meta-auditing a persona-only council (every persona running on one model family), the aggregate numbers settled at a &lt;code&gt;track_record&lt;/code&gt; of &lt;strong&gt;31%&lt;/strong&gt; and an &lt;code&gt;internal_consistency&lt;/code&gt; ceiling of &lt;strong&gt;65%&lt;/strong&gt;. The audit deliberately exhausted the obvious levers: evolving the persona system-prompt trailers across five versions, fixing the data pipeline that scored outcomes, and splitting personas across a larger and a smaller model of the &lt;em&gt;same&lt;/em&gt; family. None of it moved the ceiling. That is the signature of a limit that lives in the model weights, not in the prompt.&lt;/p&gt;
&lt;p&gt;The interpretation is straightforward and a little humbling: when consensus is built from one model's weights, agreement measures conformity, not correctness. A 65% internal-consistency ceiling means the personas couldn't even reliably agree with &lt;em&gt;themselves&lt;/em&gt; across runs, and a 31% track record means their confident consensus was usually not the thing that actually held up. Prompt engineering could change the &lt;em&gt;flavor&lt;/em&gt; of the answers but not the underlying distribution they were drawn from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three axes of real diversity
&lt;/h2&gt;

&lt;p&gt;If persona count is the wrong lever, what are the right ones? Three axes, applied in sequence, each contributing a kind of diversity that prompt variation cannot manufacture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;(A) Cross-model family.&lt;/strong&gt; Route the same question to models with different training data, different RLHF, and different tokenizers — e.g. one from each of two or three independent providers. Because their failure modes are uncorrelated, a weakness that all of them independently flag is a real weakness; a claim only one of them makes is suspect. This is the load-bearing axis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(B) Tool verification (one pass).&lt;/strong&gt; After the models converge, run a single web/citation check against the claims they relied on. Models confidently cite papers that don't exist and dates that are wrong; one external fact-check pass severs the hallucinations before they reach your conclusion. Label anything you can't verify as &lt;code&gt;unverified&lt;/code&gt; and drop it from the consensus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(C) Adversarial perspective (separate round).&lt;/strong&gt; Once a verified consensus exists, run a distinct round whose only job is to attack it: "assume this consensus is wrong — what single experiment would prove it?" This is not a compromise or a synthesis of the earlier rounds; it is a deliberate falsifier-generation step that produces an &lt;em&gt;experiment + observable + timeline + null-result implication&lt;/em&gt;, not more prose.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The output shape is the point. A persona council hands you a paragraph of consensus. A three-axis council hands you a &lt;em&gt;weakest link&lt;/em&gt;, a list of &lt;em&gt;verified citations&lt;/em&gt;, and a &lt;em&gt;falsifiable prediction&lt;/em&gt; — three things you can act on immediately (run the experiment, dig deeper, or discard the idea). In one head-to-head application the actionable ROI was roughly an order of magnitude higher, almost entirely because the deliverable changed kind, not just quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Aggregator Bottleneck
&lt;/h2&gt;

&lt;p&gt;Here is the trap that quietly destroys multi-family setups: you fan out to three different model families, collect three genuinely diverse answers — and then ask &lt;em&gt;one&lt;/em&gt; model to summarize them. That aggregator's alignment acts as a funnel. Its RLHF rewards balanced, agreeable, smoothed-over prose, so it averages the dissent away and re-homogenizes exactly the diversity you paid for. You did the expensive cross-family work and then threw the result through a single-model bottleneck at the last step.&lt;/p&gt;
&lt;p&gt;This was identified empirically: when the adversarial round was written by the same aggregator model that ran the rest of the pipeline, an outside-family model pointed out — correctly — that the aggregator had funneled the earlier diversity into its own house style. The remedy: do not let one model both diversify and conclude. Have the adversarial round (axis C) written by an &lt;em&gt;external&lt;/em&gt; family, or by the outlier voice from round one — not by the aggregator. Preserve dissent as raw branches rather than collapsing it into a synthesized middle; the median of disagreeing experts is frequently the one position none of them would defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ΔEVD test: is your reframing real or theatrical?
&lt;/h2&gt;

&lt;p&gt;A subtler failure is &lt;em&gt;Prompt Framing Lock-in&lt;/em&gt;: when all your families converge on the same weakest link in nearly identical words, the cause may not be that they agree — it may be that your single shared prompt framed the problem so narrowly that no model could escape it. The instinct is to add a "reframing layer" (a router that rewrites the prompt, a per-family persona generator, an outcome-first reformulation). But every reframing option merely &lt;em&gt;moves&lt;/em&gt; the lock-in to a different layer; you can't assume it helped. So measure it.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;ΔEVD test&lt;/strong&gt; (Embedding Variance Delta) makes the decision empirical instead of hopeful:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Send the raw prompt to N model families → embed the N answers
   with a neutral embedder → compute MPCD0
   (Mean Pairwise Cosine Distance).
2. Apply ONE reframing option → re-run the N families → MPCD1.
3. Decide on ΔMPCD = MPCD1 - MPCD0:
     &amp;gt; +0.15        keep the reframing (real diversity gain)
     +0.05..+0.15   marginal — hold; try another round/reframing
     &amp;lt;= +0.05       discard it as theatrical noise; keep raw prompt&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The harsh-but-honest conclusion from running this test on itself: most reframings fall at or below the +0.05 floor, which means they were theater. Treat the raw prompt as the most honest baseline and only adopt a reframing that clears the +0.15 bar on actual embedding distance. This is the same discipline as not collapsing dissent into a median — you don't get to &lt;em&gt;claim&lt;/em&gt; diversity you can't &lt;em&gt;measure&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations and when persona councils are still fine
&lt;/h2&gt;

&lt;p&gt;Three caveats keep this honest. First, cross-family diversity is necessary but not sufficient: the academic consensus (e.g. work on multi-agent debate in 2025) is blunt that a debate &lt;em&gt;cannot exceed the accuracy of its strongest participant&lt;/em&gt; — diversity surfaces and weights candidate answers, it does not conjure correctness that none of the participants possessed. If all your models are weak on a topic, a council of them is still weak. Second, naive iterative debate and majority voting can actively &lt;em&gt;entrench&lt;/em&gt; an initial error through model conformity; the gains come from &lt;em&gt;careful&lt;/em&gt; diversity, argument-quality weighting, and preserving dissent — not from more rounds. Third, the 31% / 65% numbers are from one specific persona-council implementation's self-audit; the ceiling will differ across setups, and the cross-family ROI multiplier is a single observed comparison, not a benchmark — treat it as directional.&lt;/p&gt;
&lt;p&gt;And don't over-apply this. The three-axis pattern is for decisions where being wrong does real damage: new system designs, claims you're about to ship, intuitions you suspect touch an unsolved problem, a thesis your single-model review couldn't crack. For a quick gut-check on a low-stakes opinion — a color choice, a naming preference, a sanity skim — a single fast call (persona or otherwise) is entirely adequate and the full pipeline is overkill. The skill is matching the verification depth to the cost of being wrong, not running the heavy machinery on everything.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>Claude Code '400: no low surrogate in string' on every turn: repairing a permanently broken session transcript</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 29 Jun 2026 00:00:08 +0000</pubDate>
      <link>https://dev.to/hexisteme/claude-code-400-no-low-surrogate-in-string-on-every-turn-repairing-a-permanently-broken-dcp</link>
      <guid>https://dev.to/hexisteme/claude-code-400-no-low-surrogate-in-string-on-every-turn-repairing-a-permanently-broken-dcp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/claude-code-400-no-low-surrogate-jsonl-repair.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A Claude Code session that returns &lt;code&gt;API Error: 400 ... not valid JSON: no low surrogate in string&lt;/code&gt; on every turn is poisoned by a lone UTF-16 surrogate (a code point in U+D800–U+DFFF) baked into its on-disk transcript, and the fix is to close that session, strip only those lone surrogates from the offending line of the &lt;code&gt;.jsonl&lt;/code&gt; file while leaving real emoji untouched, re-serialize that one line, and then &lt;code&gt;claude --resume&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The poison is already on disk and is precisely targetable: a normal emoji is a single Python code point (e.g. U+1F9ED) and can never fall inside the surrogate range U+D800–U+DFFF, so deleting only that range removes the broken half-character with zero collateral damage to valid text. A cheap C-level byte pre-filter (scan for the \ud escape or raw ED A0–BF bytes before doing any per-line json.loads) cut a 174-file transcript scan from 3.4s to 1.1s, making it cheap enough to run automatically on every session start.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why every turn fails: the surrogate is in the transcript, not the network
&lt;/h2&gt;

&lt;p&gt;Claude Code persists each session to a JSONL transcript on disk (one JSON object per line, under your projects directory). Every turn replays the accumulated history back to the API. So if a single byte sequence in that history is invalid, the API rejects &lt;em&gt;every&lt;/em&gt; subsequent request with the same error, at the same byte offset — the session is permanently bricked, and reopening it doesn't help because the bad data is reloaded from the file.&lt;/p&gt;
&lt;p&gt;The specific failure is &lt;code&gt;400 The request body is not valid JSON: no low surrogate in string: line 1 column N&lt;/code&gt; (the mirror-image variant is &lt;code&gt;no high surrogate&lt;/code&gt;). Non-BMP characters — emoji like 🧭, some extended CJK ideographs — are encoded in UTF-16 as a &lt;strong&gt;pair&lt;/strong&gt; of surrogate code units: a high surrogate (U+D800–U+DBFF) followed by a low surrogate (U+DC00–U+DFFF). When a large tool output is truncated by a length limit and the cut lands exactly between the two halves of a pair, one orphaned half survives. That lone surrogate gets written into the transcript, replayed on every turn, and the API's strict JSON parser refuses it.&lt;/p&gt;
&lt;p&gt;The triggering pattern is mundane: dumping a big, emoji-heavy blob into the context — a worker log peppered with status emoji, a daily-report job's output, a verbose ingest run — right before a large body that gets truncated mid-pair. Content-heavy projects (anything generating a lot of natural-language or creative text with non-BMP characters) re-hit this &lt;em&gt;per session&lt;/em&gt;, not once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safe repair: strip only U+D800–U+DFFF, leave real emoji intact
&lt;/h2&gt;

&lt;p&gt;The key insight that makes the fix safe is a property of Python's &lt;code&gt;str&lt;/code&gt;: a valid emoji is a single code point (🧭 is U+1F9ED), so it can &lt;em&gt;never&lt;/em&gt; land in the surrogate range U+D800–U+DFFF. Anything you find in that range is, by definition, a broken half. So you can delete exactly those code points and every legitimate character — emoji included — is left byte-for-byte untouched. You are not "removing emoji"; you are removing the orphaned halves that should never have been on disk.&lt;/p&gt;
&lt;p&gt;The manual version of the fix, when you don't have a script handy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import json

# read the one offending line, parse it, walk every string,
# drop only lone surrogates, re-serialize compactly
obj = json.loads(line)

def strip(o):
    if isinstance(o, str):
        return "".join(c for c in o if not (0xD800 &amp;lt;= ord(c) &amp;lt;= 0xDFFF))
    if isinstance(o, list):
        return [strip(x) for x in o]
    if isinstance(o, dict):
        return {k: strip(v) for k, v in o.items()}
    return o

fixed = json.dumps(strip(obj), ensure_ascii=False, separators=(",", ":"))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Write &lt;code&gt;fixed&lt;/code&gt; back as that single line, leaving every other line of the transcript exactly as it was. Always copy the file to a &lt;code&gt;.bak&lt;/code&gt; first. Re-serializing only the broken line keeps the diff minimal and preserves the rest of the conversation verbatim. After the rewrite, &lt;code&gt;claude --resume &lt;/code&gt; and the 400 is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close the session before you touch the file
&lt;/h2&gt;

&lt;p&gt;This is the step people skip, and it silently undoes the repair. While a session is open, Claude Code holds the transcript and will overwrite your edited file from its in-memory state — your fix vanishes the moment the next turn flushes. You must close the target session first, or verify nothing holds the file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lsof -- ~/.claude/projects//.jsonl&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Empty output means it's safe to edit. A good repair tool checks this for you and refuses (skips) any transcript that is currently held open, so an automated pass can never corrupt a live session. The trade-off: the one session you most want to fix — the one throwing 400s right now — may be the one you have open, so a fully automatic pass can skip exactly that file. That's the case where you fall back to the manual close-then-fix once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it cheap enough to auto-repair on every session start
&lt;/h2&gt;

&lt;p&gt;Because a content-heavy project re-breaks per session, a one-time manual fix isn't durable — you want the repair to run automatically &lt;em&gt;before&lt;/em&gt; you ever see the error. The natural place is a SessionStart hook that scans recently-modified transcripts and silently cleans the closed ones. The problem is that per-line &lt;code&gt;json.loads&lt;/code&gt; over every transcript in your projects tree is too slow to run on every launch.&lt;/p&gt;
&lt;p&gt;The fix is a cheap byte-level pre-filter that runs before any JSON parsing. A lone surrogate only persists to disk in two shapes, and both are detectable by scanning raw bytes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The JSON escape &lt;code&gt;\ud...&lt;/code&gt; (some serializers emit unpaired surrogates as a &lt;code&gt;\uXXXX&lt;/code&gt; escape; valid UTF-8 text never produces a literal &lt;code&gt;\ud&lt;/code&gt; on disk).&lt;/li&gt;
&lt;li&gt;The raw three-byte UTF-8 surrogate encoding &lt;code&gt;ED A0–BF&lt;/code&gt; (valid UTF-8 only allows &lt;code&gt;ED&lt;/code&gt; followed by &lt;code&gt;80–9F&lt;/code&gt;, so &lt;code&gt;ED&lt;/code&gt; + &lt;code&gt;A0–BF&lt;/code&gt; is unambiguously a surrogate).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A file with neither signal is provably clean and skips the expensive parse entirely. In practice this matters: a 174-file scan dropped from &lt;strong&gt;3.4s to 1.1s&lt;/strong&gt;, cheap enough to run on every session start. Run it scoped to recent days only, quiet unless something was actually fixed, and skipping any open transcript via &lt;code&gt;lsof&lt;/code&gt;. A representative one-shot invocation in a hook:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3 fix-jsonl-surrogates.py --fix-all --recent 3 --quiet&lt;/code&gt;&lt;/pre&gt;

## Why you can't prevent it upstream (honest limitation)

&lt;p&gt;This is a repair pattern, not a prevention pattern, and that's a deliberate concession to where the truncation happens. The cut that orphans a surrogate occurs &lt;em&gt;inside&lt;/em&gt; the harness's own truncation logic, between the model output and the transcript write. The user-facing hook lifecycle (PreToolUse, PostToolUse, Stop, SessionStart, and so on) fires around lifecycle events, not in the middle of serializing the request body — so there is no interception point that can stop the bad byte from being written in the first place. Making the recovery fast and automatic is the only lever you actually control.&lt;/p&gt;
&lt;p&gt;The pre-filter approach also has a real gap: if you immediately &lt;code&gt;--resume&lt;/code&gt; the very session that's broken, the auto-repair pass may find the file held open and skip it (correctly, to avoid clobbering live state), so coverage is not 100% — that's the one case needing a manual close-then-fix. And do not lean on &lt;code&gt;/compact&lt;/code&gt; as a fix: if a lone surrogate survives into the summary, the 400 persists; if it works, it worked by luck, not by design. The only genuine frequency-reduction is behavioral: avoid dumping huge emoji-saturated blobs (verbose logs, status-emoji-heavy output) into the context wholesale, or ASCII-ify such logs at the source.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>cli</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>If an LLM Extracts the Inputs, Is Your Deterministic Score Really Deterministic? Stopping Provenance Laundering</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 27 Jun 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/if-an-llm-extracts-the-inputs-is-your-deterministic-score-really-deterministic-stopping-5c4p</link>
      <guid>https://dev.to/hexisteme/if-an-llm-extracts-the-inputs-is-your-deterministic-score-really-deterministic-stopping-5c4p</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/deterministic-score-llm-provenance-laundering.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;No — a scoring function that consumes whatever values an LLM hands it is only deterministic in name; the LLM's judgment launders straight through the "deterministic" gate, and you close the hole with three rules (host-verified FACT sourcing, FACT-only scoring, and an asymmetric penalty where bad signals are penalized regardless of provenance while good signals only score when verified) plus multi-round adversarial testing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The load-bearing trick is an asymmetric-penalty mechanism: an unverified input can only ever lower a score, never raise it. Bad signals are penalized regardless of where they came from (so you can't dodge a penalty by routing the bad news through a weak source), while good signals are credited only when they carry a FACT provenance. We hardened this through three rounds of adversarial review, with an attacker-satisfaction score climbing 58 → 71 → 96 as each round peeled back a deeper laundering channel — and the decisive case was a candidate that scored 94/ADOPT on 9 web-sourced (unverified) signals plus a single real FACT, which the fixed gate correctly flipped to AVOID.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The trap: "the LLM only extracts, Python decides" is not enough
&lt;/h2&gt;

&lt;p&gt;A common and sensible architecture for trustworthy automation is to split labor: a language model reads messy sources (web pages, docs, API responses) and &lt;em&gt;extracts&lt;/em&gt; structured inputs, then a plain deterministic function &lt;em&gt;scores&lt;/em&gt; those inputs. The appeal is obvious — the model never gets to invent the verdict, so the verdict is reproducible and auditable. Teams describe this as "the LLM extracts, the code decides," and treat the output as deterministic.&lt;/p&gt;
&lt;p&gt;The problem is that determinism of the &lt;em&gt;function&lt;/em&gt; says nothing about the integrity of its &lt;em&gt;inputs&lt;/em&gt;. If the scoring code consumes any value that is present, then the model's judgment re-enters through the input channel and the "deterministic gate" becomes a laundering channel for exactly the judgment you tried to remove. The function &lt;code&gt;f(x)&lt;/code&gt; is perfectly deterministic; it is &lt;code&gt;x&lt;/code&gt; that the model controls. Calling the result deterministic is a category error unless you also gate what &lt;code&gt;x&lt;/code&gt; is allowed to be.&lt;/p&gt;
&lt;p&gt;Concretely, imagine a generic scoring pipeline that rates a candidate (a library to adopt, a vendor to onboard, a data record to act on) on a 0–100 scale and emits ADOPT / HOLD / AVOID. An LLM fills the input fields by reading sources. The day you ship it, the score is reproducible — and also completely steerable by whatever the model decided to write into the fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three laundering paths a deterministic score is exposed to
&lt;/h2&gt;

&lt;p&gt;Even when each input carries a provenance tag — say a 4-level ladder &lt;code&gt;FACT | INFERENCE | ASSUMPTION | UNKNOWN&lt;/code&gt; where FACT is supposed to mean "machine-fetched from a primary source" — there are three distinct ways arbitrary or model-generated input still poisons a deterministic output:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;(a) Label forgery.&lt;/strong&gt; The provenance tag is itself just another field the model writes. If the code trusts the tag, the model can stamp &lt;code&gt;FACT&lt;/code&gt; on a value it guessed. Nothing was fetched; the label lied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(b) Score laundering.&lt;/strong&gt; The scoring function consumes non-FACT values as if they were FACT — "the field is filled, so use it." A floor like "require at least N FACTs" does &lt;em&gt;not&lt;/em&gt; stop this, because counting FACTs never inspects &lt;em&gt;which&lt;/em&gt; values the score actually consumed. You can satisfy the FACT count with one real fact and still drive the whole score off nine inferences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(c) Penalty dodging.&lt;/strong&gt; If a bad signal is only penalized when it arrives as a FACT, an adversary routes the bad news through a weak source (label it INFERENCE) and the penalty silently disappears. This is a perverse incentive baked into the gate: weak sourcing becomes a way to suppress red flags.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The measured failure that made this concrete: a candidate built from 9 web-sourced (unverified) signals plus a single genuine FACT scored &lt;code&gt;94 → ADOPT&lt;/code&gt;. A FACT-count floor passed it. The score was "deterministic" and wrong, because it had laundered nine pieces of model judgment into a confident green light.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: three rules plus one asymmetry
&lt;/h2&gt;

&lt;p&gt;You close all three paths with three rules and a single asymmetry. The rules establish what counts as trustworthy input; the asymmetry guarantees that anything failing those rules can only ever &lt;em&gt;hurt&lt;/em&gt; a score, never help it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. FACT means machine-fetched AND host-verified — fail closed.&lt;/strong&gt; Do not trust the source label. Verify that the value's URL host is on a whitelist for the claimed source type, and reject subdomain spoofing. An unregistered host means the value cannot be FACT: demote it and record a gap. This kills path (a).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def host_matches(source, url):
    allow = HOST_WHITELIST.get(source)   # canonical hosts per source type
    if not allow:
        return False                     # fail-closed: unknown source type
    h = host_of(url)
    # exact host or a true subdomain; blocks evil-example.com.attacker.net
    return any(h == a or h.endswith("." + a) for a in allow)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. The score is driven by FACT only.&lt;/strong&gt; The scoring function trusts a value only when &lt;code&gt;provenance is FACT&lt;/code&gt;; every non-FACT value resolves to a &lt;em&gt;conservative default&lt;/em&gt;, never to its raw model-supplied number. Ban "the field is known, so use it." This kills path (b).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def num(ev, default):
    if ev.provenance is not Provenance.FACT or ev.value is None:
        return default, True   # non-FACT -&amp;gt; conservative value, unverified=True
    return float(ev.value), False&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3. Asymmetric penalty.&lt;/strong&gt; A &lt;em&gt;bad&lt;/em&gt; signal is penalized regardless of provenance — so you cannot dodge a penalty by laundering the bad news through a weak source. A &lt;em&gt;good&lt;/em&gt; signal is credited only when it is FACT (rule 2 already guarantees this). The combined effect is the load-bearing invariant: unverified input can lower a score but never raise it. This kills path (c).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# bad signal: penalize on is_known, provenance-independent
if maintainer_count.is_known and maintainer_count.value &amp;lt;= 1:
    demote("single-maintainer-risk")
# good signal: crediting is FACT-only, enforced by num() above&lt;/code&gt;&lt;/pre&gt;

## Why one round of review is not enough: 58 → 71 → 96

&lt;p&gt;These defects do not surface in a single pass. They are layers of the &lt;em&gt;same&lt;/em&gt; threat — "this deterministic gate is laundering judgment" — and each adversarial round peels back the next layer. In our hardening of a generic adoption-scoring pipeline, an adversarial reviewer (you can use a second model, a colleague, or a structured red-team checklist) was asked each round to break the determinism claim, and its satisfaction climbed across three rounds: &lt;code&gt;58 → 71 → 96&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Round 1 (58):&lt;/strong&gt; caught label forgery — the gate trusted the provenance tag. Fix: host-verified whitelist (rule 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round 2 (71):&lt;/strong&gt; caught source laundering — values claiming an official-docs origin were accepted without host checks, so the attacker spoofed the &lt;em&gt;source type&lt;/em&gt; rather than the label. Fix: fail-closed host matching extended to every source class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round 3 (96):&lt;/strong&gt; caught score laundering — the FACT-count floor passed a score driven by non-FACT values. This is the deepest layer and the one most teams miss: a count of trusted inputs is not the same as a score &lt;em&gt;built only from&lt;/em&gt; trusted inputs. Fix: FACT-only consumption (rule 2) plus the asymmetric penalty.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A single round of review would have shipped after fixing label forgery and declared victory at 58, leaving the actual laundering channel wide open. The lesson generalizes: when the threat is "my safety boundary is being bypassed," iterate the adversary until its satisfaction plateaus, because each fix exposes the next assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pin the defense with an attack matrix regression test
&lt;/h2&gt;

&lt;p&gt;Once you have the three rules, freeze them as tests so a future refactor cannot quietly re-open a laundering path. Encode the attack matrix directly: each row is an adversarial input, each expected outcome is the gate's correct response.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# attack matrix as a regression suite
cases = [
    ("normal FACT, verified host",        EXPECT_PASS),
    ("label forgery (INFERENCE -&amp;gt; FACT)",  EXPECT_DEMOTE_AND_GAP),
    ("source spoof (fake official host)",  EXPECT_REJECT),
    ("score laundering (FACT count met,"
     " score driven by non-FACT values)", EXPECT_NO_CREDIT),
    ("penalty dodge (bad signal as weak"
     " source)",                          EXPECT_PENALTY_APPLIED),
    ("missing key signal",                 EXPECT_DEMOTE),
]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two tests most worth writing are the ones that catch the subtle paths: (1) a candidate whose FACT count is satisfied but whose &lt;em&gt;numeric&lt;/em&gt; inputs are non-FACT must receive conservative defaults, not credit; and (2) a bad signal arriving as INFERENCE must still trigger its penalty. If both pass, your gate has the invariant "unverified input can only lower the score." Re-run the matrix on every change to the gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitation: code cannot close the injection boundary
&lt;/h2&gt;

&lt;p&gt;These three rules close the laundering paths that live &lt;em&gt;inside&lt;/em&gt; the scoring pipeline. They do not — and cannot — close the boundary one layer up: the honesty of the session that injects the inputs. Host verification proves a value came from &lt;code&gt;registry.example.com&lt;/code&gt;; it does not prove the model fetched the &lt;em&gt;right&lt;/em&gt; page, reported the response faithfully, or didn't omit a contradicting field it saw. A sufficiently capable injecting agent that controls its own tool calls can fetch a real primary source and still cherry-pick or misreport from it.&lt;/p&gt;
&lt;p&gt;The correct posture is to separate this residual boundary honestly rather than pretend code closed it. Mark it explicitly as an intrinsic trust boundary in your threat model — the same way you would label "we trust the OS kernel" — and mitigate it with controls that live outside the deterministic function: pinning the exact fetch URL and re-fetching it independently at verification time, diffing the model's reported value against a second independent fetch, sampling outputs for human spot-checks, and logging raw responses so a claimed FACT is auditable after the fact. Claiming a deterministic gate fully neutralizes a dishonest injector is itself a form of laundering — of &lt;em&gt;your&lt;/em&gt; assurance to whoever consumes the score.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>security</category>
    </item>
    <item>
      <title>macOS: nslookup works but curl and Python "Could not resolve host" — the mDNSResponder zombie</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 26 Jun 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/hexisteme/macos-nslookup-works-but-curl-and-python-could-not-resolve-host-the-mdnsresponder-zombie-3727</link>
      <guid>https://dev.to/hexisteme/macos-nslookup-works-but-curl-and-python-could-not-resolve-host-the-mdnsresponder-zombie-3727</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/macos-mdnsresponder-zombie-dns.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;nslookup&lt;/code&gt; resolves a host fine but &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;pip&lt;/code&gt;, and Python (&lt;code&gt;requests&lt;/code&gt;/&lt;code&gt;httpx&lt;/code&gt;) fail with "Could not resolve host," your &lt;strong&gt;&lt;code&gt;mDNSResponder&lt;/code&gt; daemon has almost certainly entered a non-responsive "zombie" state&lt;/strong&gt; — and the fix is to restart it (&lt;code&gt;sudo killall -9 mDNSResponder&lt;/code&gt;), not to touch your API keys, SDK versions, or code.&lt;/p&gt;

&lt;p&gt;This failure is maddening because every signal points the wrong way. &lt;code&gt;nslookup example.com&lt;/code&gt; returns a clean IP, so DNS "works." Your network is up. Your code didn't change. Yet &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;pip install&lt;/code&gt;, and every Python HTTP call die with "Could not resolve host." People burn hours rotating API keys, downgrading SDKs, and editing config files. None of that is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-DNS-path asymmetry (your fastest diagnosis)
&lt;/h2&gt;

&lt;p&gt;macOS resolves DNS over &lt;strong&gt;two independent paths&lt;/strong&gt;, and the asymmetry &lt;em&gt;is&lt;/em&gt; the diagnosis:&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;Who uses it&lt;/th&gt;
&lt;th&gt;Goes through mDNSResponder?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nslookup&lt;/code&gt;, &lt;code&gt;dig&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No — queries DNS servers directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System resolver&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;curl&lt;/code&gt;, Python, &lt;code&gt;pip&lt;/code&gt;, most apps (&lt;code&gt;getaddrinfo()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes — routes to the daemon&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The daemon can keep its PID alive while silently refusing to answer. So the direct path (nslookup) succeeds and the system-resolver path (curl) fails &lt;em&gt;on the exact same host&lt;/em&gt;. When one path works and the other fails, you are not looking at a network, key, or code problem — you are looking at a sick daemon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm it in 10 seconds
&lt;/h2&gt;

&lt;p&gt;Use the daemon path directly so you're testing the same route curl uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Uses the mDNSResponder path (same as curl/Python). Empty result = zombie.&lt;/span&gt;
dscacheutil &lt;span class="nt"&gt;-q&lt;/span&gt; host &lt;span class="nt"&gt;-a&lt;/span&gt; name example.com

&lt;span class="c"&gt;# ...while the direct path still returns a valid IP:&lt;/span&gt;
nslookup example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;dscacheutil&lt;/code&gt; comes back empty but &lt;code&gt;nslookup&lt;/code&gt; returns an IP, the daemon is confirmed. One more check proves routing and TLS are fine and isolates the daemon as the &lt;em&gt;sole&lt;/em&gt; cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--resolve&lt;/span&gt; example.com:443:&amp;lt;IP-from-nslookup&amp;gt; https://example.com
&lt;span class="c"&gt;# Succeeds? Then DNS resolution is the only broken thing.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix it weakest-tool-first
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) flush + reload (least disruptive)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dscacheutil &lt;span class="nt"&gt;-flushcache&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;killall &lt;span class="nt"&gt;-HUP&lt;/span&gt; mDNSResponder

&lt;span class="c"&gt;# 2) kickstart the service&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;launchctl kickstart &lt;span class="nt"&gt;-k&lt;/span&gt; system/com.apple.mDNSResponder

&lt;span class="c"&gt;# 3) universal hammer (if kickstart prints "Could not find service" —&lt;/span&gt;
&lt;span class="c"&gt;#    the service path differs across macOS versions)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;killall &lt;span class="nt"&gt;-9&lt;/span&gt; mDNSResponder mDNSResponderHelper
&lt;span class="c"&gt;#    launchd's KeepAlive immediately restarts it with a fresh PID.&lt;/span&gt;

&lt;span class="c"&gt;# verify&lt;/span&gt;
pgrep &lt;span class="nt"&gt;-l&lt;/span&gt; mDNSResponder &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; dscacheutil &lt;span class="nt"&gt;-q&lt;/span&gt; host &lt;span class="nt"&gt;-a&lt;/span&gt; name example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why agents and long-lived dev boxes hit this repeatedly
&lt;/h2&gt;

&lt;p&gt;The zombie is usually triggered by &lt;strong&gt;connection-pool exhaustion&lt;/strong&gt;. Many concurrent outbound long-poll connections — several MCP servers, a local inference/LLM server, and a background job all holding sockets open at once — push &lt;code&gt;mDNSResponder&lt;/code&gt; into high CPU until it stops answering.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Measured case:&lt;/strong&gt; 30+ concurrent long-poll connections drove &lt;code&gt;mDNSResponder&lt;/code&gt; to &lt;strong&gt;77% CPU&lt;/strong&gt; and into the non-responsive state. The recovery alone doesn't stop recurrence — you have to find and cut the connection source. Watch for the active connection count climbing (&lt;code&gt;lsof -i -P -n | wc -l&lt;/code&gt; in the 80+ range) and &lt;code&gt;mDNSResponder&lt;/code&gt; CPU above ~30%.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  "Disabled" in config does not mean the process is dead
&lt;/h2&gt;

&lt;p&gt;A subtle trap when hunting the connection source: a server can be flagged off at the code/config level (an &lt;code&gt;ENABLED=False&lt;/code&gt; switch) while its OS process &lt;em&gt;keeps running&lt;/em&gt; and keeps holding its long-poll connections. The flag stopped new work from being dispatched but never killed the process, so it kept feeding the pile-up. When auditing, check &lt;code&gt;ps&lt;/code&gt;/&lt;code&gt;pgrep&lt;/code&gt; for the actual process and its elapsed time — not just the config flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgrep &lt;span class="nt"&gt;-lf&lt;/span&gt; &amp;lt;server-name&amp;gt;        &lt;span class="c"&gt;# still there?&lt;/span&gt;
ps &lt;span class="nt"&gt;-o&lt;/span&gt; pid,etime,%cpu,command &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;pid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prevent recurrence — and the honest limitation
&lt;/h2&gt;

&lt;p&gt;Two durable fixes: &lt;strong&gt;reduce concurrent long-poll load&lt;/strong&gt; (trim always-on servers/MCP endpoints, kill stale inference servers, and on a box that runs for many days, restart &lt;code&gt;mDNSResponder&lt;/code&gt; on a schedule), or install a &lt;strong&gt;watchdog LaunchDaemon&lt;/strong&gt; that kickstarts the daemon when its CPU crosses a threshold.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Honest caveat:&lt;/strong&gt; this exact two-path asymmetry is macOS-specific — it's &lt;code&gt;mDNSResponder&lt;/code&gt;/&lt;code&gt;getaddrinfo&lt;/code&gt; behavior, not Linux's &lt;code&gt;nsswitch&lt;/code&gt;/&lt;code&gt;resolv.conf&lt;/code&gt;. And a watchdog that calls &lt;code&gt;launchctl kickstart&lt;/code&gt; needs root: implement it as a LaunchDaemon running as root with a narrowly scoped script, not a broad sudo NOPASSWD rule. The watchdog is a band-aid — the real fix is capping concurrent connections.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;More notes on building &amp;amp; running an AI agent fleet at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>networking</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A file-based work-bus for orchestrating a fleet of agent CLIs — coordination without a message broker</title>
      <dc:creator>John</dc:creator>
      <pubDate>Wed, 24 Jun 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/hexisteme/a-file-based-work-bus-for-orchestrating-a-fleet-of-agent-clis-coordination-without-a-message-1kdo</link>
      <guid>https://dev.to/hexisteme/a-file-based-work-bus-for-orchestrating-a-fleet-of-agent-clis-coordination-without-a-message-1kdo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://hexisteme.github.io/notes/file-based-agent-work-bus.html" rel="noopener noreferrer"&gt;hexisteme notes&lt;/a&gt;, part of a series on building and running an AI agent fleet.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To coordinate a fleet of independent AI agent CLIs without a message broker or a heavy framework, use a &lt;strong&gt;filesystem work-bus&lt;/strong&gt;: the orchestrator decomposes a goal into a graph of subtasks, writes a &lt;code&gt;Task&lt;/code&gt; file per subtask, and polls for the &lt;code&gt;Result&lt;/code&gt; file each worker writes back — every file written atomically. The durable coordination state lives on disk as files: language-agnostic, debuggable with &lt;code&gt;ls&lt;/code&gt;, surviving restarts, and self-healing because an absent worker is skipped and logged instead of failing the run.&lt;/p&gt;

&lt;p&gt;Say you have several AI agents, each an independent installed CLI — one gathers information, one writes copy, one builds an app scaffold — and you want to run a goal that needs several of them in sequence. The heavyweight answers are an in-process framework (LangGraph, an AutoGPT-style loop) or a message broker (Redis, Kafka, RabbitMQ). Both are more than a single-operator fleet needs: a framework couples your workers into one process and one language, and a broker is infrastructure you now have to run, secure, and monitor.&lt;/p&gt;

&lt;p&gt;There's a lighter primitive that fits this shape: a work-bus made of files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism
&lt;/h2&gt;

&lt;p&gt;A conductor process owns a shared directory — the bus. To run a goal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decompose&lt;/strong&gt; the goal into a directed acyclic graph of subtasks (e.g. &lt;em&gt;gather → narrate → build&lt;/em&gt;).&lt;/li&gt;
&lt;li&gt;For each ready subtask, &lt;strong&gt;write a &lt;code&gt;Task&lt;/code&gt; file&lt;/strong&gt; into the bus, tagged with the capability it needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poll&lt;/strong&gt; for the matching &lt;code&gt;Result&lt;/code&gt; file, with a short backoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absorb&lt;/strong&gt; each result, validate it, and release the next subtasks in the graph.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one rule that makes this safe is &lt;strong&gt;atomic writes&lt;/strong&gt;: write each record to a temp path and &lt;code&gt;rename&lt;/code&gt; it into place. Rename is atomic on POSIX filesystems, so a reader either sees the whole file or nothing — never a half-written record. Task and Result are typed records (a small pydantic schema), and the conductor keeps a registry of what's in flight.&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;# atomic publish — a reader never sees a partial record
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_suffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.tmp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump_json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# atomic on POSIX
&lt;/span&gt;
&lt;span class="c1"&gt;# the conductor loop
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;topo_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dag&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bus&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.task.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bus&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.result.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...)&lt;/span&gt;   &lt;span class="c1"&gt;# durable: waits for the file
&lt;/span&gt;    &lt;span class="nf"&gt;absorb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  This is state, not events — by design
&lt;/h2&gt;

&lt;p&gt;It's fair to ask whether a file work-bus is just an event bus in disguise. It isn't, and the distinction is the reason it works. An event bus is &lt;em&gt;push&lt;/em&gt;: producers emit ephemeral events, and anything not listening at that instant misses them. A file work-bus is &lt;em&gt;state&lt;/em&gt;: the &lt;code&gt;Task&lt;/code&gt; and &lt;code&gt;Result&lt;/code&gt; records are durable files that stay until consumed. A worker that starts late, or restarts mid-run, still finds its task waiting. (I argued the same principle for &lt;em&gt;monitoring&lt;/em&gt; a fleet in &lt;a href="https://hexisteme.github.io/notes/pull-vs-event-bus-agent-fleet.html" rel="noopener noreferrer"&gt;state is truth, events are rumors&lt;/a&gt; — here it shows up again for &lt;em&gt;coordinating&lt;/em&gt; one.)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why push is fine here but not for monitoring.&lt;/strong&gt; You &lt;em&gt;build and control&lt;/em&gt; these workers, so you can make them read and write the bus. Monitoring is the opposite case — you watch components you don't control, so you pull their state instead. Coordination of owned workers via durable files, monitoring of unowned components via state scans: both lean on durable state over ephemeral events.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Routing by capability, not by name
&lt;/h2&gt;

&lt;p&gt;The conductor doesn't hard-wire "send step 2 to worker X." Each worker advertises capabilities; each subtask declares the capability it needs; the conductor matches them at dispatch time by finding a healthy worker that advertises the required capability. Add or remove a worker and routing adapts — there's no wiring diagram to edit. This is what lets one conductor coordinate a heterogeneous, changing fleet through a single uniform contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graceful degradation: skip the absent worker
&lt;/h2&gt;

&lt;p&gt;The most important behavior for a fleet that's still being built: &lt;strong&gt;a missing worker must not fail the run.&lt;/strong&gt; If a subtask needs a capability no healthy worker currently advertises, the conductor marks that node &lt;em&gt;skipped&lt;/em&gt; (a logged &lt;code&gt;worker_absent&lt;/code&gt;), continues the rest of the graph, and synthesizes from whatever completed. On day one, when most workers don't exist yet, the conductor still runs end-to-end and produces partial output — and the skip log is a precise to-do list of which capabilities to build next. A gap is reported, not crashed on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust the bus like a network boundary
&lt;/h2&gt;

&lt;p&gt;Worker output is untrusted input crossing a boundary, and the bus treats it that way. Every result is parsed into a strict schema before absorption; mismatches (say, casing differences between the wire format and internal enums) are coerced and normalized at the seam. Load-bearing claims carry a provenance label and must include evidence — a claim that arrives marked &lt;code&gt;FACT&lt;/code&gt; with no evidence IDs is rejected at parse time, not trusted. The typed contract is what lets independent workers, written in different languages by you at different times, interoperate without the conductor having to trust any of them blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;No stop condition on re-routing.&lt;/strong&gt; Capability-based routing has a sharp edge: if a node can be re-routed to "any worker advertising capability C" and results keep failing validation, a naive conductor can re-route in an unbounded loop. A file work-bus needs an explicit per-node attempt budget (and a dead-letter outcome) or it can spin. Durability and decoupling are the wins; a bounded retry policy is the cost you must pay to claim them safely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When to reach for a real broker
&lt;/h2&gt;

&lt;p&gt;This pattern fits a &lt;em&gt;small, heterogeneous fleet running tasks that take seconds to minutes&lt;/em&gt;, coordinated by one operator. If you need high-throughput, low-latency fan-out across many producers and consumers, run a real message bus — the file-bus's polling and single-conductor model won't keep up. Match the tool to the failure that hurts: for a solo fleet, the pain is operational overhead and brittle coupling, and a directory of atomic files removes both.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More notes on building an AI agent fleet — &lt;a href="https://hexisteme.github.io/notes/pull-vs-event-bus-agent-fleet.html" rel="noopener noreferrer"&gt;why I rejected an event bus for monitoring&lt;/a&gt;, &lt;a href="https://hexisteme.github.io/notes/fact-vs-inference-provenance-ai-agent.html" rel="noopener noreferrer"&gt;labeling facts vs inferences&lt;/a&gt;, &lt;a href="https://hexisteme.github.io/notes/rdu-reusable-decision-units.html" rel="noopener noreferrer"&gt;reusable decision units&lt;/a&gt; — at &lt;a href="https://hexisteme.github.io/notes/" rel="noopener noreferrer"&gt;hexisteme.github.io/notes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
