<?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: Ian</title>
    <description>The latest articles on DEV Community by Ian (@ianwieds).</description>
    <link>https://dev.to/ianwieds</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%2F384278%2F61b90b66-d678-4b60-b1a2-a008abb006d0.jpg</url>
      <title>DEV Community: Ian</title>
      <link>https://dev.to/ianwieds</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ianwieds"/>
    <language>en</language>
    <item>
      <title>The analysis counted zero bridges. The app exposes 51 methods.</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/the-analysis-counted-zero-bridges-the-app-exposes-51-methods-25ok</link>
      <guid>https://dev.to/ianwieds/the-analysis-counted-zero-bridges-the-app-exposes-51-methods-25ok</guid>
      <description>&lt;p&gt;I wanted to know how a desktop app's window talks to its main process. In Electron, that is the first question behind almost every "build me a feature like theirs" job: which calls cross the bridge, and what answers them on the other side.&lt;/p&gt;

&lt;p&gt;So I handed a shipped app to a reverse-engineering tool and read the summary. Three context bridges. None of them belonged to the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool everyone starred this week
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/morluto/rea" rel="noopener noreferrer"&gt;REA&lt;/a&gt;, short for Reverse Engineer Anything, is an MCP server and CLI that lets a coding agent inspect apps it has no source for. Native binaries go through Hopper, Ghidra or IDA. JavaScript and Electron apps, .NET assemblies, Android packages and websites have their own paths. It was #1 on GitHub trending on Sunday with 25,599 new stars in a day (about 118,000 total, MIT licensed), and its Hacker News thread reached &lt;a href="https://news.ycombinator.com/item?id=50028275" rel="noopener noreferrer"&gt;774 points and 327 comments&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The pitch is direct: see a feature you like in someone's app, and have your agent work out how it is built. The npm release, 6.4.0, ships 127 CLI commands (I counted them from &lt;code&gt;--help&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Most of those need a native disassembler. The static JavaScript path does not, so that is the one I ran. I skipped &lt;code&gt;rea setup&lt;/code&gt;, which registers REA in your agents' MCP configs, and called the CLI through npx in a scratch folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The run
&lt;/h2&gt;

&lt;p&gt;My target was &lt;a href="https://github.com/electron/fiddle" rel="noopener noreferrer"&gt;Electron Fiddle&lt;/a&gt; 0.39.0, the Electron team's own MIT-licensed playground app. Open source on purpose: I could check every answer against the real code. I downloaded the macOS arm64 zip from its GitHub release, matched its SHA-256 to the digest GitHub publishes, and pulled out only &lt;code&gt;app.asar&lt;/code&gt; (21.9 MB). Nothing was installed or launched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; rea-agents@latest analyze-javascript-application /abs/path/app.asar &lt;span class="nt"&gt;--json&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; fiddle.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About three minutes later I had 43 MB of JSON. REA inventoried 89 files, parsed 71 JavaScript files and visited 2,386,900 syntax nodes without executing a line of the app. The Electron summary, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;context_bridge_apis: 3        exposed_api_members: 5
ipc.operations: 26            ipc.literal_channels: 11
ipc.dynamic_channel_operations: 6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those findings is real, and none of them is Fiddle's. The 11 literal channels (&lt;code&gt;clipboard:readText&lt;/code&gt;, &lt;code&gt;open-github&lt;/code&gt;, &lt;code&gt;get-displays&lt;/code&gt; and the rest) come from the example fiddles the app bundles as starter templates, under &lt;code&gt;static/show-me/&lt;/code&gt;. The six dynamic channels and the &lt;code&gt;__SENTRY_IPC__&lt;/code&gt; bridge belong to the Sentry SDK. Fiddle's own bridge, &lt;code&gt;window.ElectronFiddle&lt;/code&gt;, is not in the counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the answer went
&lt;/h2&gt;

&lt;p&gt;It was not silently dropped, and that part deserves full credit.&lt;/p&gt;

&lt;p&gt;The preload that defines the bridge is a 9,768-byte file. REA parsed it completely, then attached two &lt;code&gt;unknown&lt;/code&gt; nodes to it. One says semantic analysis of the file "was not started" because the parent had reserved its remaining result capacity. The other says its relationships exceeded the result budget. The main-process bundle got its own entry: the analyzer worker ran out of its default 1,024 MiB heap there. The graph carries 110 unknown nodes in total, and its coverage field reads &lt;code&gt;partial&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;REA works through the files in path order, and the preload was number 23 of 89. Ahead of it sat the 2.9 MB main bundle, the 4.9 MB renderer bundle and Monaco's language workers. The small file holding the answer arrived after the budget was gone. Raising the analyzer heap to 4,096 MiB did not help: the run took about 20 minutes instead of 3, the main bundle ran out of heap again, and every count above came back the same.&lt;/p&gt;

&lt;p&gt;REA's docs describe the fix, a &lt;code&gt;--source-path&lt;/code&gt; flag that analyzes only the files you name. It lives on the main branch. The npm release I ran answered &lt;code&gt;Unknown flag: --source-path&lt;/code&gt;, and to be fair, the install docs warn that main runs ahead of npm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line experiment
&lt;/h2&gt;

&lt;p&gt;So I made the selection by hand. I copied that one preload into its own folder and analyzed it alone. Ten seconds later: zero bridges, zero IPC operations, and this time the graph's coverage read &lt;code&gt;complete&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reason sits in the minified file itself. Webpack does not leave &lt;code&gt;require("electron")&lt;/code&gt; where the code uses it. It wraps the require in a numbered module and hands the result out through its own loader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="mi"&gt;288&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;electron&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="c1"&gt;// ...later in the same file&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;288&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;REA's semantic graph did see the reads of &lt;code&gt;ipcRenderer.send&lt;/code&gt; and &lt;code&gt;ipcRenderer.invoke&lt;/code&gt;. But it marked the binding &lt;code&gt;E&lt;/code&gt; as an unknown value, and REA only treats something as Electron's API when it can prove the alias. So &lt;code&gt;E.contextBridge.exposeInMainWorld(...)&lt;/code&gt; never counted as a bridge. The Sentry preload it did catch is a tiny separate file that calls &lt;code&gt;require("electron")&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;I changed one expression in my copy, &lt;code&gt;_(288)&lt;/code&gt; to &lt;code&gt;require("electron")&lt;/code&gt;, and ran it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;context_bridge_apis: 1   (ElectronFiddle, 51 members, 0 unknown)
ipc.operations: 48       ipc.dynamic_channel_operations: 48
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty-one members is exactly what Fiddle's &lt;code&gt;src/preload/preload.ts&lt;/code&gt; defines; I counted them in the source. All 48 channels stay dynamic (&lt;code&gt;e.IpcEvents.FIDDLE_RUN&lt;/code&gt; and so on) because the enum values live in another webpack module, and REA labels each one that way.&lt;/p&gt;

&lt;p&gt;For contrast, I pointed it at the unbundled source of Electron's archived API demos app. Seventeen seconds, 30 IPC operations over 18 literal channels, and all 12 renderer sends paired with their main-process handlers. On plain code, the static picture is very good. On a production bundle, the gaps open exactly where the bundler inserted its runtime.&lt;/p&gt;

&lt;p&gt;I could not find an open issue about following Electron through webpack's loader. I did not file one; this was a read-and-run session only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence is a lower bound
&lt;/h2&gt;

&lt;p&gt;Here is the rule I would carry into any agent workflow built on a tool like this: a static count is a floor, never a census. "Found 3 bridges" means at least three, with the rest sitting in the unknowns.&lt;/p&gt;

&lt;p&gt;REA is designed around that idea. Its graph docs store unavailable values explicitly "so absence of evidence cannot silently become evidence of absence". Every dynamic channel and every inferred send-to-handler pairing in my runs carried its own limitation string, and the top-level result repeats that missing findings do not establish absence.&lt;/p&gt;

&lt;p&gt;The weak link is whoever reads the result. An agent that turns &lt;code&gt;context_bridge_apis: 3&lt;/code&gt; into "the app exposes three APIs" has thrown away the most useful part of the answer. If I put an agent on REA to study a feature, I would make it list every unknown that touches that feature before it writes one conclusion. In my run, that list pointed straight at a 9.8 KB file a person finds in seconds with &lt;code&gt;grep exposeInMainWorld&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the real shift here. Copying a feature used to start with a guess about how it works. Now it starts with evidence, and evidence comes with a list of what it could not see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It moves very fast.&lt;/strong&gt; Versions 6.2.0, 6.3.0 and 6.4.0 shipped within about 36 hours, and 130 issues were opened on Oct 11 and 12 (UTC) alone. &lt;a href="https://github.com/morluto/rea/issues/1387" rel="noopener noreferrer"&gt;Issue #1387&lt;/a&gt;, still open, records 6.3.0 aborting on a large bundle that 6.2.0 finished; the reporter says 6.4.0 fixed it for them. Pin a version for anything you script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setup edits your agent config.&lt;/strong&gt; It shows a plan, asks for approval and keeps backups. One HN commenter still called the paste-this-prompt install "the next evolution of installation by &lt;code&gt;curl | bash&lt;/code&gt;". The CLI works without it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The legal line is yours.&lt;/strong&gt; REA's &lt;a href="https://github.com/morluto/rea/blob/main/DISCLAIMER.md" rel="noopener noreferrer"&gt;disclaimer&lt;/a&gt; rules out DRM bypass and copying proprietary code, and notes that licenses and local law may restrict reverse engineering. I picked an MIT app for that reason. Learning how a feature works and shipping someone else's code are different acts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When your agent reports that an app has none of something, do you make it show you the unknowns, or do you take the zero?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Your database has one clock. Every audit question needs two.</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Fri, 09 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/your-database-has-one-clock-every-audit-question-needs-two-1e43</link>
      <guid>https://dev.to/ianwieds/your-database-has-one-clock-every-audit-question-needs-two-1e43</guid>
      <description>&lt;p&gt;A customer disputes an invoice.&lt;/p&gt;

&lt;p&gt;"You billed us Starter on March 1. We were on Pro from February 20."&lt;/p&gt;

&lt;p&gt;Support opens the &lt;code&gt;customers&lt;/code&gt; table. It says Pro. The &lt;code&gt;updated_at&lt;/code&gt; column says March 15. The row that existed on March 1, the one the billing job actually read, is gone. Nobody can say what the system believed when it sent that invoice. Everyone can only say what it believes now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two clocks, not one
&lt;/h2&gt;

&lt;p&gt;That dispute has two different times in it, and most app schemas collapse them into one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Valid time&lt;/strong&gt; is when something was true in the world. Acme was on Pro from February 20.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transaction time&lt;/strong&gt; is when your system found out. You learned about the upgrade on March 15.&lt;/p&gt;

&lt;p&gt;A bi-temporal database keeps both, for every fact, and never overwrites either. You can then ask four kinds of question instead of one: what is true now, what was true on a date, what did we believe at some past moment, and what did we believe at that moment about some other date. The billing dispute is the last kind. "On March 1, what did we think Acme's plan was on March 1?"&lt;/p&gt;

&lt;p&gt;Most app databases keep one clock at best, and only for the latest row. An &lt;code&gt;UPDATE&lt;/code&gt; destroys the old value. &lt;code&gt;updated_at&lt;/code&gt; is a transaction time for the latest write only. An &lt;code&gt;effective_from&lt;/code&gt; column gives you valid time, but the first time someone corrects it in place, the old belief is gone. The usual patch is an audit table filled by triggers, and it tends to be partial, hand-rolled, and never queried until the day a lawyer asks.&lt;/p&gt;

&lt;p&gt;So the bug class this fixes is not exotic. It is any question of the form "why did the system do that, back then?": a disputed invoice, a compliance review, a model that made a recommendation from data that has since been corrected.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small database that keeps both
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/project-minigraf/minigraf" rel="noopener noreferrer"&gt;Minigraf&lt;/a&gt; is an embedded graph database in Rust that stores every fact with both clocks. It was a Show HN this week (&lt;a href="https://news.ycombinator.com/item?id=49963394" rel="noopener noreferrer"&gt;thread&lt;/a&gt;, 34 points and 29 comments when I read it), and it sits at 117 stars. Embedded means SQLite-style: a library in your process and one &lt;code&gt;.graph&lt;/code&gt; file, no server. It ships bindings for Python, Node, browser WASM, Swift, Kotlin and C, and queries are Datalog.&lt;/p&gt;

&lt;p&gt;The two clocks show up as two query keys. This is the shape from the project's own demo file and grammar tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight clojure"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;:find&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;?company&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="no"&gt;:as-of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="no"&gt;:valid-at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"2024-01-01"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="no"&gt;:where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;:alice&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;:employment/company&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;?company&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;&lt;code&gt;:valid-at&lt;/code&gt; picks the date in the world. &lt;code&gt;:as-of&lt;/code&gt; picks the moment in the database's history, either as a transaction counter (&lt;code&gt;:as-of 3&lt;/code&gt;) or a UTC timestamp (&lt;code&gt;:as-of "2024-01-15T10:00:00Z"&lt;/code&gt;). Writes take &lt;code&gt;:valid-from&lt;/code&gt; and &lt;code&gt;:valid-to&lt;/code&gt; when you want to backdate or bound a fact. Leave out &lt;code&gt;:valid-at&lt;/code&gt; and you get what is valid now.&lt;/p&gt;

&lt;h2&gt;
  
  
  I replayed the invoice dispute
&lt;/h2&gt;

&lt;p&gt;I did not build it from source. I downloaded the v2.0.3 prebuilt macOS binary from the project's GitHub releases, checked its SHA-256 against the published one, and piped Datalog into its REPL in a scratch folder. It is a 1.2 MB executable.&lt;/p&gt;

&lt;p&gt;The script writes Acme's plan, waits, notes the time (the moment "we billed"), then applies the correction from support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./minigraf
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'(transact {:valid-from "2026-01-01"} [[:acme :plan :starter]])'&lt;/span&gt; | &lt;span class="nv"&gt;$MG&lt;/span&gt; &lt;span class="nt"&gt;--file&lt;/span&gt; acme.graph
&lt;span class="nb"&gt;sleep &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;BILLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;2

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'(retract [[:acme :plan :starter]])'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'(transact {:valid-from "2026-01-01" :valid-to "2026-02-20"} [[:acme :plan :starter]])'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'(transact {:valid-from "2026-02-20"} [[:acme :plan :pro]])'&lt;/span&gt; | &lt;span class="nv"&gt;$MG&lt;/span&gt; &lt;span class="nt"&gt;--file&lt;/span&gt; acme.graph
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the two questions support actually needs answered (output trimmed to the answer rows):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(query [:find ?plan :as-of "2026-10-06T05:49:36Z" :valid-at "2026-03-01" :where [:acme :plan ?plan]])
:starter

(query [:find ?plan :valid-at "2026-03-01" :where [:acme :plan ?plan]])
:pro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole dispute in two lines. When we billed, the system believed Starter, so the invoice was consistent with what it knew. Today it knows Pro, so the invoice was wrong and owes a credit. Both answers come from the same file, and neither required anyone to have built an audit table in advance.&lt;/p&gt;

&lt;p&gt;The transaction ids it printed are Unix milliseconds, so the &lt;code&gt;:as-of&lt;/code&gt; timestamp is plain wall-clock time. I checked them against my &lt;code&gt;BILLED&lt;/code&gt; stamp with a one-line Node script, and it sat between the first write and the correction, as it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprised me
&lt;/h2&gt;

&lt;p&gt;My first attempt skipped the retract. I wrote Starter from January 1, then Pro from February 20, and asked about March 1. I got both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;?plan
--------------------
:starter
:pro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Valid time does not supersede anything by itself. A new value does not end the old one; an open window stays open until you close it. Datomic, the system this design borrows from, has a cardinality-one schema flag that retracts the old value for you. Minigraf has no schema yet (optional validation is on its roadmap), so closing the window is your job.&lt;/p&gt;

&lt;p&gt;On v2.x, closing it means retracting the fact and re-asserting it with a &lt;code&gt;:valid-to&lt;/code&gt;. Even writing the same fact again with a new end date does not replace the old window, which the maintainer filed as &lt;a href="https://github.com/project-minigraf/minigraf/issues/435" rel="noopener noreferrer"&gt;issue #435&lt;/a&gt; and scheduled for v3.0.0. A related bug, &lt;a href="https://github.com/project-minigraf/minigraf/issues/436" rel="noopener noreferrer"&gt;#436&lt;/a&gt;, notes that a window whose end comes before its start is accepted without an error.&lt;/p&gt;

&lt;p&gt;My correction also ran as three separate transactions, and I could see the gap: &lt;code&gt;:as-of 2&lt;/code&gt;, after the retract and before the re-assert, returns no plan at all. The Rust API has explicit write transactions (&lt;code&gt;begin_write&lt;/code&gt;, then &lt;code&gt;commit&lt;/code&gt;) to make that all-or-nothing, but the REPL pipe I used does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How mature is it
&lt;/h2&gt;

&lt;p&gt;Be fair about scale. This is a solo-maintained project, v2.0.3 shipped this week, and there are 43 open issues. The maintainer is unusually candid about them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A pinned known-issues list&lt;/strong&gt; (&lt;a href="https://github.com/project-minigraf/minigraf/issues/421" rel="noopener noreferrer"&gt;#421&lt;/a&gt;). On every v2.x release, two values of one attribute written in a single transaction can read back as one value. The workaround is one value per call; the fix needs a new file format and lands in v3.0.0. Checkpoints are also not crash-atomic yet, though committed data survives in the write-ahead log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A production-readiness tracker&lt;/strong&gt; (&lt;a href="https://github.com/project-minigraf/minigraf/issues/383" rel="noopener noreferrer"&gt;#383&lt;/a&gt;) whose stated goal is to make it safe as "the only copy of important data". Read that as: not yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The npm package did not load on my Mac.&lt;/strong&gt; &lt;code&gt;npm install minigraf&lt;/code&gt; gave me 2.0.3, but &lt;code&gt;require('minigraf')&lt;/code&gt; threw &lt;code&gt;MODULE_NOT_FOUND&lt;/code&gt;. The loader looks for an unscoped &lt;code&gt;minigraf-darwin-universal&lt;/code&gt;, while npm installs &lt;code&gt;@minigraf/darwin-universal&lt;/code&gt;, still pinned at 1.0.0. The release binary worked fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The HN thread pushed back on the premise too. One commenter, who maintains another embedded graph database, asked why a Cypher database with timestamp properties on edges would not cover this. Another said that whenever they question a new graph database, the answer comes back as Postgres. A third could not picture the real-world queries.&lt;/p&gt;

&lt;p&gt;My answer to the first two: timestamp properties give you valid time. The second clock only exists if you never update in place, and that is a discipline most teams lose the first time someone fixes a typo in production. Postgres can do it too, with an append-only table and separate valid-time and recorded-time columns. The point is not which engine. The point is that the history has to be the default, not a feature someone remembers to bolt on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should care
&lt;/h2&gt;

&lt;p&gt;If you store anything a person or a model acts on, someone will eventually ask what the system believed when it acted. Agent memory is the obvious new case: an agent recommended something on Tuesday, a fact it used was corrected on Thursday, and now you need to replay Tuesday.&lt;/p&gt;

&lt;p&gt;You do not need Minigraf to take the lesson. Append instead of overwrite, and give every row a "true from" and a "recorded at" that are different columns. If you want to see what it feels like when the database does that for you, the binary and a ten-line script are enough.&lt;/p&gt;

&lt;p&gt;What is the last question you could not answer because your database had already overwritten the answer?&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>A PSD carries Photoshop's answer key. The UI has none.</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Thu, 08 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/a-psd-carries-photoshops-answer-key-the-ui-has-none-16ee</link>
      <guid>https://dev.to/ianwieds/a-psd-carries-photoshops-answer-key-the-ui-has-none-16ee</guid>
      <description>&lt;p&gt;This week I wrote a Photoshop file by hand. 390 bytes, two gray layers, the top one set to Soft Light. I filled the file's saved preview with solid magenta on purpose, so any reader that just showed the preview would get caught.&lt;/p&gt;

&lt;p&gt;The reader ignored the magenta, rebuilt the image from the layers, and gave me a gray of 32. The W3C formula behind CSS &lt;code&gt;soft-light&lt;/code&gt; gives 15 for the same two layers.&lt;/p&gt;

&lt;p&gt;That reader was the command-line tool of &lt;a href="https://github.com/storytold/photocraft" rel="noopener noreferrer"&gt;PhotoCraft&lt;/a&gt;, which calls itself an open-source, clean-room reimplementation of Photoshop in pure Rust. The repo was created on September 30, passed 2,200 stars inside a week, and shipped v0.2.0 on October 5. Its own README labels it early alpha and says it is "not yet a Photoshop replacement for daily professional work."&lt;/p&gt;

&lt;p&gt;I spent an evening on one question: how does a project this young get blend math right, while its first users say the basics break?&lt;/p&gt;

&lt;h2&gt;
  
  
  The answer key inside the file
&lt;/h2&gt;

&lt;p&gt;A PSD does not only hold layers. When Photoshop saves with Maximize Compatibility on, it also writes a flattened copy of the whole image: its own render of those layers. Each such file is a question with its answer stapled to the back.&lt;/p&gt;

&lt;p&gt;PhotoCraft's test suite is built on that. &lt;code&gt;crates/io/tests/corpus.rs&lt;/code&gt; opens each real-world PSD, flattens the layers with PhotoCraft's own compositor, and compares the result with the merged image Photoshop saved. A pixel passes within 2/255. Files saved without a merged image fall back to the small JPEG thumbnail Photoshop embeds, which is also Photoshop's render.&lt;/p&gt;

&lt;p&gt;I checked that from the outside. I took 14 blend-mode test files from the MIT-licensed &lt;a href="https://github.com/psd-tools/psd-tools" rel="noopener noreferrer"&gt;psd-tools&lt;/a&gt; project, all saved by Photoshop, and wrote a short Node script that decodes each file's merged image on its own. Then I had PhotoCraft's CLI render every file from its layers and compared the two. A sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pt/multiply.psd      4096 px  exact 3993  max diff   1/255
pt/overlay.psd       4096 px  exact 3353  max diff   1/255
pt/soft-light.psd    4096 px  exact 3346  max diff   2/255
pt/vivid-light.psd   4096 px  exact 3361  max diff   2/255
pt/hue.psd           4096 px  exact 2787  max diff   2/255
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 14 landed within 2/255 of Photoshop's render. Before that impresses you too much: these files sit in PhotoCraft's own test corpus, so a pass is what its tests already promise. The interesting part is what the corpus taught the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean-room means learning from output
&lt;/h2&gt;

&lt;p&gt;The project's rule, in AGENTS.md and docs/contributing.md, is that Photoshop is studied for behavior and look only: no copied code, shaders, profiles or assets. Implementation comes from public specs (Adobe's PSD spec, ICC, ISO 32000 for blend modes) and from observation. Where the PSD spec is silent, the psd crate's README says it follows the MIT-licensed psd-tools and ag-psd docs.&lt;/p&gt;

&lt;p&gt;Observation is not a figure of speech here. &lt;code&gt;crates/compose/src/psblend.rs&lt;/code&gt; carries Photoshop-specific versions of Vivid Light and Hard Mix, and its header says they were derived from Photoshop's rendered composites in those psd-tools files. One case: a black Vivid Light layer over a white backdrop. The textbook color-burn branch returns white. PhotoCraft returns black, because that is what Photoshop's saved render shows. I ran that case and got 0.&lt;/p&gt;

&lt;p&gt;Soft Light is the same story. &lt;code&gt;crates/color/src/blend.rs&lt;/code&gt; keeps a Photoshop Soft Light that differs from the W3C definition in its upper half. Here are my hand-built files, run through the CLI, next to both formulas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node cases.mjs
sLit backdrop   4 source 254 -&amp;gt;  32  css-w3c 15  ps-formula 32
sLit backdrop  26 source 230 -&amp;gt;  71  css-w3c 67  ps-formula 71
sLit backdrop 200 source 230 -&amp;gt; 221  css-w3c 221  ps-formula 221
vLit backdrop 255 source   0 -&amp;gt;   0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I scanned all 65,536 pairs of 8-bit values. The two formulas disagree on 4,579 of them, by up to 17 levels, always on a dark backdrop under a bright layer. If you ever matched a Photoshop mockup with &lt;code&gt;mix-blend-mode: soft-light&lt;/code&gt; and the shadows came out wrong, this is one honest reason.&lt;/p&gt;

&lt;p&gt;The price of the rule shows up in the camera RAW decoder. &lt;a href="https://github.com/storytold/photocraft/issues/83" rel="noopener noreferrer"&gt;Issue #83&lt;/a&gt; explains why Nikon's compressed NEF still opens only as its embedded JPEG: the decoder needs Huffman tables the file does not carry, and the only descriptions the author found were GPL decoder source or write-ups taken from it. So they stopped. That is the project's own line, not a legal opinion, and I am not offering one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What has no answer key
&lt;/h2&gt;

&lt;p&gt;Here is my claim. PhotoCraft moves fastest exactly where a file can grade it, and breaks where nothing can.&lt;/p&gt;

&lt;p&gt;The speed is real. When I cloned it, the repo held about 231,000 lines of Rust by my count, two dozen crates in layers a build check enforces, and a CLI whose registry listed 748 commands. By the repo's own commit trailers, 138 of its 236 commits on main were co-authored with Claude, and its AGENTS.md is written for AI agents working in parallel. A grader that never sleeps suits that way of working.&lt;/p&gt;

&lt;p&gt;Then people opened the app. In &lt;a href="https://news.ycombinator.com/item?id=49958850" rel="noopener noreferrer"&gt;the HN thread&lt;/a&gt; (106 points), several testers say they could not get basic tasks done, and the maintainer says the repos went public before they were ready. The project's roadmap, to its credit, says the same thing in its "honest parity assessment." Users hit a text selection offset, 214 shortcut failures, panels resizing themselves and an immovable crop frame, all of which counted as "live" in its parity list. In its own words: "this week proved our tests miss what users hit."&lt;/p&gt;

&lt;p&gt;A crop frame has no merged image. Nothing in a file says how a drag should feel.&lt;/p&gt;

&lt;p&gt;Even the file format has a softer side than the pixels. Reading is graded by Photoshop. Writing is graded by whoever opens your file next. In &lt;a href="https://github.com/storytold/photocraft/issues/200" rel="noopener noreferrer"&gt;issue #200&lt;/a&gt;, a user re-saved 437 public PSDs, and psd-tools could not read 36 of the outputs. The fix landed after v0.2.0, and the README dropped a "byte for byte" claim in the same change.&lt;/p&gt;

&lt;p&gt;So mind which number you read. 307 of 309 psd-tools files re-open looking the same after a re-save, which is PhotoCraft agreeing with itself. The enforced floor for matching Photoshop's own render on that set is 229.&lt;/p&gt;

&lt;p&gt;The roadmap's next step reads like an answer: script 30 to 50 real tasks end to end and check each against Photoshop's output on every build. That is an answer key for workflows. Whether it can capture how a crop frame feels is the bet I would watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ran, and what I did not
&lt;/h2&gt;

&lt;p&gt;I did not launch the desktop app or build from source. I downloaded the macOS CLI zip from the v0.2.0 GitHub release, checked its hash against the release's SHA256SUMS.txt, and confirmed it is signed with a Developer ID and notarized. Then, in a scratch folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./photocraft-cli &lt;span class="nt"&gt;--version&lt;/span&gt;      &lt;span class="c"&gt;# photocraft-cli 0.2.0 (ad8632173, 2026-10-05)&lt;/span&gt;
./photocraft-cli info t-soft.psd   &lt;span class="c"&gt;# my hand-made file: two layers, "blend": "Soft Light"&lt;/span&gt;
./photocraft-cli convert pt/soft-light.psd soft-light.ppm
./photocraft-cli run t2.psd &lt;span class="nt"&gt;--cmd&lt;/span&gt; layer.layerStyle.blendingOptions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--params&lt;/span&gt; &lt;span class="s1"&gt;'{"blend":"screen"}'&lt;/span&gt; &lt;span class="nt"&gt;--out&lt;/span&gt; t2-screen.ppm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I found no hosted web version. The release ships the WebAssembly build as a zip you host yourself.&lt;/p&gt;

&lt;p&gt;Two limits on my numbers. The psd-tools files are in PhotoCraft's own corpus. And my Soft Light check compares PhotoCraft with a formula, not with Photoshop, which I did not run.&lt;/p&gt;

&lt;p&gt;When you had to clone behavior with no spec, what did you use as your answer key, and what did you do about the parts that had none?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A passing test run should leave a receipt</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Wed, 07 Oct 2026 21:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/a-passing-test-run-should-leave-a-receipt-218e</link>
      <guid>https://dev.to/ianwieds/a-passing-test-run-should-leave-a-receipt-218e</guid>
      <description>&lt;p&gt;This one covers six days, September 30 to October 5. Most of it went into one question for my workflow kit: who runs the tests, and what a green run is allowed to prove. The rest went into an OMEGA release for first-time users, two new brands started on it over the weekend, and the first player tools for the game database.&lt;/p&gt;

&lt;h2&gt;
  
  
  workkit: the kit stopped running tests
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/itw-creative-works/workkit" rel="noopener noreferrer"&gt;workkit&lt;/a&gt; is my workflow kit for Claude Code: GitHub issues as the work list, labels as the pipeline, and hooks that enforce the rules. It shipped fifteen versions in the window, 0.63.1 to 0.72.0, most of them circling one problem.&lt;/p&gt;

&lt;p&gt;At the start of the week the kit ran tests itself, at several gates. Moving an issue to QA reran the touched test files, and the commit gate wanted a full suite run. Then 0.70.0 hashed the tree to skip a rerun when nothing had changed, 0.71.1 read the TAP output instead of guessing from a file's text, and 0.71.2 learned that a test file under a &lt;code&gt;fixtures&lt;/code&gt; folder may fail on purpose. Each fix made sense, and the test flow still changed from one day to the next.&lt;/p&gt;

&lt;p&gt;0.72.0 turns it around: the kit never runs a test. A package's &lt;code&gt;npm test&lt;/code&gt; is the one command, bare for the whole suite or with file arguments for a narrowed run. workkit is set as npm's script shell, so every &lt;code&gt;npm test&lt;/code&gt; passes through it. It hashes the working tree, runs the command, and on green writes a record for that hash. That record is the receipt, and the gates only read receipts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Moving an issue to QA runs nothing. Every package that owns a touched test file needs a green record on the exact tree, or the move is refused with a list of the packages and files.&lt;/li&gt;
&lt;li&gt;Before a commit, one bare &lt;code&gt;npm test&lt;/code&gt; at the root writes the suite record the commit gate checks. &lt;code&gt;workkit prove&lt;/code&gt; does the same on a throwaway copy of exactly what is staged.&lt;/li&gt;
&lt;li&gt;A second root run on a tree that already has a receipt is refused, and so is any root run while an issue is still being built.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An agent that can run tests can also run them five times. Tying the proof to a tree hash makes "it passed" a fact about specific bytes, and any edit after the run voids the receipt on its own.&lt;/p&gt;

&lt;p&gt;One more change rides on it. A build with a test surface is now a pair of agents briefed from the issue's Contract: one writes the tests, one writes the code, and a verifier proves the new tests fail without the code. That red proof runs in a snapshot taken when the issue was claimed, gitignored build files included, because a copy built from HEAD could crash on a missing file and pass for red.&lt;/p&gt;

&lt;h2&gt;
  
  
  OMEGA 0.55.0: template to running site in three steps
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Omega-JS-Stack/omega" rel="noopener noreferrer"&gt;OMEGA&lt;/a&gt; is my framework for shipping a website, backend, desktop app, and browser extension from one project. 0.55.0 shipped on October 1, and its theme is the first ten minutes.&lt;/p&gt;

&lt;p&gt;A new user starts from the brand template, a repo with two files. &lt;code&gt;npm start&lt;/code&gt; installs OMEGA, runs the onboarding wizard, and boots the site. Both files carry a template marker, so the wizard replaces them on the first run and never on a rerun.&lt;/p&gt;

&lt;p&gt;The detail I like: one function, &lt;code&gt;missingEnvKeys&lt;/code&gt;, says which env keys a brand still owes, by target, config, and verb. Every verb's env check reads it, so a website-only brand is never asked for a backend key. &lt;code&gt;omega status&lt;/code&gt; applies the same idea to a folder: what it is, what it lacks, and the next command. It writes nothing.&lt;/p&gt;

&lt;p&gt;Underneath, devkit gained the one test runner every OMEGA package will move to, built on &lt;code&gt;node:test&lt;/code&gt; in one process. Desktop and extension switched first, and their suites (1,119 and 351 cases) passed unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two brands in a weekend, both from the template
&lt;/h2&gt;

&lt;p&gt;On Saturday two new brands started from that template, the best test onboarding could get.&lt;/p&gt;

&lt;p&gt;OddMatter is a creative agency site (private, and the new site is not live yet, so no link). The work was porting a hand-built MVP into an OMEGA web target as its own theme: six pages, 17 sections, and animated effects on one frame loop.&lt;/p&gt;

&lt;p&gt;The home hero cost about 40% of a CPU core at rest and stuttered under a CPU slowdown. The frame loop now feeds raw frame times to a small governor with no browser dependency, so a plain Node unit test covers it. When a whole window of frames averages slow, it steps the effects down a level: first the title and the stone slow, then the sky slows and caps its pixel density. It never steps back up during a page view, so the page does not flicker between looks.&lt;/p&gt;

&lt;p&gt;Suponsa is a marketplace for guest post and link insertion slots (private, no live site yet). Its first real commit is the submission flow: buyers submit through a wizard, and sellers approve, decline, ask for changes, mark paid, and deliver. The status machine lives in the backend, and a submission stores its own next moves, so a page renders without reading the seller's documents. Each move emails the other side after the write, and a failed email never fails the move.&lt;/p&gt;

&lt;p&gt;Both scaffolds also turned up framework gaps, which went back to OMEGA as issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lootlore: EXP per hour, not per swing
&lt;/h2&gt;

&lt;p&gt;Lootlore, the game database from the last post (still private, no public URL), got three tool pages: an EXP table, a training spot finder, and a scroll simulator.&lt;/p&gt;

&lt;p&gt;The training page ranks hunting maps by EXP per hour. Attack time comes from the frames of the basic attack at the worn weapon's speed tier, and kills per hour from each map's spawn supply for one player, using formulas a community site measured. The first version ranked by EXP per swing, which ignores both how fast you swing and how crowded a map is.&lt;/p&gt;

&lt;p&gt;Skill pages also open with a small live scene: a character of that class casts the skill in the game's own art, with the game's damage numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small but satisfying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;My Claude usage meter for VS Code drew 429s with several windows open, because each window polled on its own. Windows now share a gate file and a lock: one attempt per 150 seconds machine-wide, and a shared backoff that doubles up to ten minutes.&lt;/li&gt;
&lt;li&gt;Notifly, the desktop notifier my hooks fire, now runs every launch as its own process, through a &lt;code&gt;singleInstance&lt;/code&gt; switch OMEGA added for CLI-shaped desktop apps. A notification is never handed to a running copy that may have hung.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: the rest of OMEGA's packages move to the one test runner, and the two new brands head for their first deploy. If your agents or your CI decide when tests run, I would like to hear how you keep them from running twice. Comments open.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your blocklist is 2.2 MB of strings. It only needs 550 KB.</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Wed, 07 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/your-blocklist-is-22-mb-of-strings-it-only-needs-550-kb-5d1b</link>
      <guid>https://dev.to/ianwieds/your-blocklist-is-22-mb-of-strings-it-only-needs-550-kb-5d1b</guid>
      <description>&lt;p&gt;110,162 blocked domains. 550,810 bytes. Zero collisions.&lt;/p&gt;

&lt;p&gt;Those are the numbers my own Node script printed on Monday night, after it hashed a popular ad blocklist down to five bytes per domain. Then I diffed the file against the one a $2 chip downloads every week. Same bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Pi-hole on a $2 chip
&lt;/h2&gt;

&lt;p&gt;The chip runs &lt;a href="https://github.com/M-Abozaid/esp32-c3-adblock" rel="noopener noreferrer"&gt;esp32-c3-adblock&lt;/a&gt;, a DNS sinkhole for the ESP32-C3. Ad and tracker domains get &lt;code&gt;0.0.0.0&lt;/code&gt; back; everything else is forwarded upstream. It hit #12 on GitHub trending this Monday with 196 stars in a day, and Tom's Hardware and XDA have both written it up.&lt;/p&gt;

&lt;p&gt;The C3 has no PSRAM. Per the maintainer in &lt;a href="https://github.com/M-Abozaid/esp32-c3-adblock/issues/3" rel="noopener noreferrer"&gt;issue #3&lt;/a&gt;, it has about 400 KB of RAM in total and roughly 170 KB free at runtime. The default blocklist is over 2 MB of text. So the project never holds the domains at all.&lt;/p&gt;

&lt;p&gt;Each domain becomes a 64-bit FNV-1a hash, cut down to its low 40 bits. The hashes are sorted and written to flash as a flat file of 5-byte rows. A query hashes the name and then each parent (&lt;code&gt;ads.tracker.example.com&lt;/code&gt;, &lt;code&gt;tracker.example.com&lt;/code&gt;, &lt;code&gt;example.com&lt;/code&gt;) and searches for each, so one listed domain covers its subdomains.&lt;/p&gt;

&lt;p&gt;I did not flash the firmware. I read it and the Python list builder, then rebuilt the core trick in Node to check the numbers myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  I rebuilt it in Node
&lt;/h2&gt;

&lt;p&gt;The script ports the builder's parser (hosts lines, bare domains, &lt;code&gt;||domain^&lt;/code&gt; rules), runs on the same two public lists the project uses by default (StevenBlack's hosts file and Hagezi Light), and does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FNV_OFFSET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0xcbf29ce484222325&lt;/span&gt;&lt;span class="nx"&gt;n&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;FNV_PRIME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x100000001b3&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fnv64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FNV_OFFSET&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;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asUintN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;FNV_PRIME&lt;/span&gt;&lt;span class="p"&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;h&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="nx"&gt;MASK40&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&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;hashes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fnv64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;MASK40&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;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Float64Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hashes&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// write each as 5 little-endian bytes: that file IS the blocklist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 40-bit value fits safely in a JavaScript number, so only the hashing needs BigInt. The run, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;domains          : 110,162
40-bit entries   : 110,162  collisions 0  (birthday expects 0.006)
blob             : 550,810 bytes = 0.53 MiB; 5.00 B/domain
same as strings  : 2,201,104 bytes of text (avg 19.0 chars)
hash+sort time   : 398 ms
flat search      : hits avg 15.8 max 17; misses avg 16.8 max 17
vs release blob  : shared 110,162, identical bytes true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My file matched that day's weekly release from the project byte for byte. That is the title: 2.2 MB of domain text (before any pointers or a hash table) becomes 550 KB of hashes, a quarter of the size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The README's numbers hold up
&lt;/h2&gt;

&lt;p&gt;I checked each claim against my run and the birthday bound, which says n random b-bit values hold about n² / 2^(b+1) colliding pairs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;141k domains in 0.67 MB.&lt;/strong&gt; 141,000 × 5 bytes is 705,000 bytes, which is 0.67 MiB. Checks out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 collisions at 141k.&lt;/strong&gt; The bound expects 0.009 pairs. My 110k list had 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;32 bits would cost about 7 collisions at 250k.&lt;/strong&gt; The bound gives 7.3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 collision at 537k.&lt;/strong&gt; The bound expects 0.13, so one is a 1-in-8 event: plausible, slightly unlucky. I could not rebuild that list (Hagezi retired its source folder); today's aggressive pair builds to 357,201 domains, with 0 collisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;About 18 flash reads per lookup.&lt;/strong&gt; A flat binary search took at most 17 reads on 110k and 19 on 357k.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is already out of date, in a good way. &lt;a href="https://github.com/M-Abozaid/esp32-c3-adblock/pull/2" rel="noopener noreferrer"&gt;PR #2&lt;/a&gt;, merged October 4, keeps a 4,096-entry sample of the table in RAM (20 KB). A lookup does 12 steps in RAM, then one flash read of a bucket, about 27 rows by my count. The maintainer measured roughly 115 queries per second going to 165, and fully cached lookups also stop near 165. The search is no longer the bottleneck; per-packet UDP and WiFi work is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collision that actually matters
&lt;/h2&gt;

&lt;p&gt;Here is the part I would argue with. The builder counts collisions inside the list, and the README calls one of those an over-block. But if two blocked domains share a hash, both were going to be blocked anyway. That collision costs nothing. It even saves five bytes.&lt;/p&gt;

&lt;p&gt;The collision that costs you is an &lt;em&gt;unlisted&lt;/em&gt; name landing on a listed hash. That is a false block: some site you never meant to block just stops resolving. Its rate per lookup is just n / 2^b, the list size over the hash space.&lt;/p&gt;

&lt;p&gt;So I measured it. I took 253,678 real domains from Hagezi's ultimate list that are not in the default list, used them as queries, and swept the hash width:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bits&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;Pairs inside list (expected)&lt;/th&gt;
&lt;th&gt;False blocks (expected)&lt;/th&gt;
&lt;th&gt;Rate per lookup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;360 (362)&lt;/td&gt;
&lt;td&gt;1,682 (1,660)&lt;/td&gt;
&lt;td&gt;6.5e-3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;22 (23)&lt;/td&gt;
&lt;td&gt;108 (104)&lt;/td&gt;
&lt;td&gt;4.1e-4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0 (1.4)&lt;/td&gt;
&lt;td&gt;7 (6.5)&lt;/td&gt;
&lt;td&gt;2.6e-5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0 (0.01)&lt;/td&gt;
&lt;td&gt;0 (0.03)&lt;/td&gt;
&lt;td&gt;1.0e-7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The measurements sit right on the formula. At 40 bits, roughly one lookup in ten million lands on a stranger's hash. Say a home network touches 20,000 distinct names in a year. At about three lookups each, it should expect to wrongly block none of them. At 32 bits, that same year would cost you one or two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store hashes, not strings
&lt;/h2&gt;

&lt;p&gt;The idea is bigger than ad blocking. &lt;strong&gt;If all you ever ask a list is "is this in it?", you do not need the list. You need fixed-size hashes and a known false-positive budget.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Who should care: anyone holding a large allowlist or blocklist in memory. A spam filter's domain list, a tracker list shipped inside a browser extension, leaked key fingerprints on an edge function, user IDs behind a feature flag. Each is usually a &lt;code&gt;Set&lt;/code&gt; of strings. Each could be a sorted buffer of fixed-size rows that needs no parsing at load and a fraction of the memory.&lt;/p&gt;

&lt;p&gt;Pick the width from the budget, not from habit. Choose an acceptable false-block rate per lookup, then solve n / 2^b for b. A million entries at one in ten million needs about 44 bits, so six bytes.&lt;/p&gt;

&lt;p&gt;The HN thread mostly asked for a Bloom filter instead. A contributor tried one in &lt;a href="https://github.com/M-Abozaid/esp32-c3-adblock/pull/5" rel="noopener noreferrer"&gt;PR #5&lt;/a&gt;: 2 KB of bits. With 141k entries setting two bits each, my arithmetic says all but about 3 in 100 million of those bits end up set, so it passes everything. At the same one-in-ten-million rate, a Bloom filter needs about 33.5 bits per entry and 23 probes. The sorted table spends 40 bits and one search. Bloom filters shine when 1% false positives is fine. Here they would save a sixth of the space and pay for it in random reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;False blocks are permanent.&lt;/strong&gt; A name that collides collides every time, until the list changes. Nothing can explain the block; there is no string to show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot list or edit the entries.&lt;/strong&gt; The maintainer notes in &lt;a href="https://github.com/M-Abozaid/esp32-c3-adblock/issues/8" rel="noopener noreferrer"&gt;issue #8&lt;/a&gt; that an allow rule cannot carve one subdomain out of a blocked parent. On the device, domains you add by hand live in a separate 200-entry list in RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any change is a rebuild.&lt;/strong&gt; Hashing and sorting took my laptop 0.4 seconds, so compute is not the cost. Shipping a new file is. The project rebuilds it weekly in CI. There is also no header, so any file whose size divides by five loads as a list. &lt;a href="https://github.com/M-Abozaid/esp32-c3-adblock/pull/21" rel="noopener noreferrer"&gt;PR #21&lt;/a&gt; is adding a magic number and a CRC for exactly that.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I ran my own Node script (155 lines, no dependencies) on the real public lists, plus a few lines of arithmetic for the Bloom numbers. I did not run the project's Python builder or flash the firmware. Throughput and RAM figures are the maintainer's.&lt;/p&gt;

&lt;p&gt;What is the biggest list you hold as strings today that only ever answers "is it in there?", and what false-positive rate could you actually live with?&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>javascript</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Let the AI click. Don't let it grade.</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Tue, 06 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/let-the-ai-click-dont-let-it-grade-3abo</link>
      <guid>https://dev.to/ianwieds/let-the-ai-click-dont-let-it-grade-3abo</guid>
      <description>&lt;p&gt;I broke the Add button on a tiny todo page and ran my agent test against it, with a scripted stand-in where the model would go.&lt;/p&gt;

&lt;p&gt;The agent step came back green. The test still failed, one line later, on a plain locator that expected "1 open" and read "0 open".&lt;/p&gt;

&lt;p&gt;That one-line gap is the whole design question for AI end-to-end tests: which steps get a model, and which stay code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goals in English, checks in code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/tester-army/e2e" rel="noopener noreferrer"&gt;e2e&lt;/a&gt;, from TesterArmy, was #2 on GitHub trending on Oct 5, with 1,398 stars in a day. It is an Apache-2.0 TypeScript test runner for the web (Playwright underneath) and for iOS and Android. A test mixes three kinds of step. &lt;code&gt;agent.act&lt;/code&gt; hands a goal in plain English to a model that drives the app. &lt;code&gt;agent.assert&lt;/code&gt; asks a model to judge the screen. &lt;code&gt;screen&lt;/code&gt; and &lt;code&gt;expect&lt;/code&gt; are ordinary locators that never call a model.&lt;/p&gt;

&lt;p&gt;Here is the test I wrote against my page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent step: add a todo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;act&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;add a todo named {title}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Buy milk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1 open&lt;/span&gt;&lt;span class="dl"&gt;'&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;Nothing new so far. Plenty of AI testing tools can do the first run. The interesting part is the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second run is the product
&lt;/h2&gt;

&lt;p&gt;When the company launched its hosted product on HN in June, the sharpest question came from poisonborz: hand-written E2E tests are "deterministic AND cheap to run", so how does an agent compete? The open-source framework answers with a replay cache.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;agent.act&lt;/code&gt; that a later check verified gets recorded: the actions, the controls by role and name, and what the screen should look like afterward. On the next run, the runner replays that recording with no model call. If a control is gone or the end state does not match, the model picks up from wherever the app is.&lt;/p&gt;

&lt;p&gt;I wanted to watch this without a model. I had no API key for this post, so I used the custom executor hook from the docs and wrote a 17-line scripted stand-in. It finds the textbox and the button in the redacted screen text, types and taps through the runner's checked actions, and logs every call to a file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run 1: &lt;code&gt;Cache 1 missed&lt;/code&gt;, executor called once.&lt;/li&gt;
&lt;li&gt;Runs 2 and 3: &lt;code&gt;Cache 1 replayed&lt;/code&gt;, executor never called.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The recording is a 1.4 KB JSON file. It holds two actions (&lt;code&gt;type "Buy milk" into textbox "New todo"&lt;/code&gt;, then &lt;code&gt;tap button "Add"&lt;/code&gt;), the anchors that appeared (&lt;code&gt;status "1 open"&lt;/code&gt;, &lt;code&gt;listitem "Buy milk"&lt;/code&gt;), and the one that went away (&lt;code&gt;status "0 open"&lt;/code&gt;). With a real model, that act costs calls once, then nothing until the UI moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the cache cannot replay
&lt;/h2&gt;

&lt;p&gt;Here is the catch, and it decides where the AI belongs. &lt;code&gt;agent.assert&lt;/code&gt;, &lt;code&gt;agent.waitFor&lt;/code&gt; and &lt;code&gt;agent.extract&lt;/code&gt; always run live. A judgment is never cached.&lt;/p&gt;

&lt;p&gt;Some rough math, using the sample cost table in the project's debugging docs: one act at $0.0198 over three model calls, one assert at $0.0114. Those are their example figures, not my measurements. A suite with 40 acts and 40 asserts comes to about $1.25 on a cold run. Replay every act perfectly and it still costs $0.46, on every run, forever. All of that is grading. Swap the asserts for locator checks and a warm run makes no model calls at all.&lt;/p&gt;

&lt;p&gt;So my claim is this. Let the model do the clicking, because clicking is the part that gets recorded, and the part that breaks with every redesign. Keep the grading in code wherever an exact value exists. Save &lt;code&gt;agent.assert&lt;/code&gt; for outcomes no locator can state, like whether a chatbot's reply answers the question, and pay for it on purpose.&lt;/p&gt;

&lt;p&gt;My broken button shows the second reason. After the replay hit an &lt;code&gt;end-mismatch&lt;/code&gt;, my stand-in took over, tapped the dead button again, and reported success, the way any agent grades its own work. The &lt;code&gt;expect&lt;/code&gt; caught it. In &lt;a href="https://github.com/tester-army/e2e/issues/884" rel="noopener noreferrer"&gt;issue #884&lt;/a&gt;, a user hit the real version with a decision-model executor: it claimed a tab step was done without clicking in 4 of 5 runs, and "only our &lt;code&gt;toHaveURL&lt;/code&gt; assertion caught it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a run goes red
&lt;/h2&gt;

&lt;p&gt;Determinism is not one thing, and e2e splits failures by exit code. I hit three of them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The product broke.&lt;/strong&gt; My dead button: exit 1, &lt;code&gt;ASSERTION_FAILED&lt;/code&gt;, expected "1 open", observed "0 open", with a text dump of the screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model was unreachable.&lt;/strong&gt; My first run used the default config with no key. The locator test passed. The agent test failed with &lt;code&gt;MODEL_PROVIDER_FAILED&lt;/code&gt;, filed under category &lt;code&gt;infrastructure&lt;/code&gt;, exit 3. A dead key does not pose as a bug in your app, which is exactly what CI needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The recording went stale.&lt;/strong&gt; I renamed "Add" to "Create". Under &lt;code&gt;--strict-cache&lt;/code&gt;, the run failed with &lt;code&gt;REPLAY_STALE&lt;/code&gt; and exit 2, and never called the executor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without the flag, that same rename passed. The replay typed the text, could not find the old button, and handed off (&lt;code&gt;Cache 1 handed off&lt;/code&gt;). The step took 14.6 seconds instead of about 0.2; the docs give a missing control 15 seconds to show up. The docs warn about this default themselves: a broken recording slides quietly into a live model run on every build until someone re-records it. In CI, I would turn strict on from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The defaults that cost money
&lt;/h2&gt;

&lt;p&gt;Two settings matter more than which model you pick.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;e2e init&lt;/code&gt; adds &lt;code&gt;.e2e/cache/&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;, and the cache defaults to read-only in CI. Put together, CI starts with no recordings and cannot save any, so every act runs live on every pull request until you commit the cache folder. The docs explain how. It is opt-in.&lt;/p&gt;

&lt;p&gt;Second, CI retries a failed test once by default, and retries never replay. That is the honest call, since a replayed retry would just repeat what failed. It also means a flaky test pays for its second attempt in model calls, not just minutes.&lt;/p&gt;

&lt;p&gt;Then there is &lt;a href="https://github.com/tester-army/e2e/issues/850" rel="noopener noreferrer"&gt;issue #850&lt;/a&gt;, still open. After a hand-off, the wait a recording stores can grow on each run until it hits 120 seconds. The reporter found 8 of 26 entries pinned there, and a two-line patch took their cached suite from 6 min 51 s to 1 min 35 s.&lt;/p&gt;

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

&lt;p&gt;I ran it on Node 24.15 in a throwaway folder: &lt;code&gt;npx e2e init --yes&lt;/code&gt;, then &lt;code&gt;npm install&lt;/code&gt; (40 packages, 72 MB). I served a static page with Python, and Chromium came from my existing Playwright cache. The locator test passed 20 times in a row with &lt;code&gt;--repeat-each 20&lt;/code&gt;, in 6 seconds. The agent test ran once with no key, to see the failure, then with my scripted executor, to watch the cache record, replay, go stale and catch a regression. I also set &lt;code&gt;E2E_TELEMETRY_DISABLED=1&lt;/code&gt;; the CLI sends anonymous usage data unless you opt out.&lt;/p&gt;

&lt;p&gt;I did not run a real model. So I have no numbers of my own on how often the built-in agent finishes a goal, how flaky a live act is, or what a step really costs. The dollar figures above are the docs' sample. The project is also pre-1.0, and 0.17.0 shipped two breaking changes on Oct 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd put the AI
&lt;/h2&gt;

&lt;p&gt;If I adopt this, the rule fits in four lines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;agent.act&lt;/code&gt; to get somewhere: sign in, fill the wizard, reach the screen under test.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;expect&lt;/code&gt; right after every act. It is what makes the recording exist, and what catches an agent grading its own homework.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent.assert&lt;/code&gt; only where the right answer is a meaning, not a string.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--strict-cache&lt;/code&gt; in CI, with the cache folder committed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Which check in your suite could only a model make, and which one have you been handing to a model out of habit?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop making your big model answer multiple choice</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Sat, 03 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/stop-making-your-big-model-answer-multiple-choice-4n9n</link>
      <guid>https://dev.to/ianwieds/stop-making-your-big-model-answer-multiple-choice-4n9n</guid>
      <description>&lt;p&gt;A support email lands in an agent's inbox.&lt;/p&gt;

&lt;p&gt;Before the agent writes a single word back, it makes five small calls. Is this a prompt injection? How urgent is it? What does the customer actually want? Which tool runs first? Is the draft backed by the docs it pulled?&lt;/p&gt;

&lt;p&gt;Every one of those is a multiple-choice question. In most stacks I have seen, every one goes to the biggest model on hand, which writes a paragraph that a regex then picks apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  A model that only picks
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/firelex/jeff" rel="noopener noreferrer"&gt;Jeff&lt;/a&gt; is a 0.8B open model built for exactly those calls. You give it a situation and a list of labeled options. It gives back a probability for each option from one forward pass. It writes no text, so there is nothing to parse.&lt;/p&gt;

&lt;p&gt;The author calls it a "System 1" model: the fast, reflexive half of a stack, with a big model as the slow, deliberate half. It was built at home as an open take on a hosted decision API, and &lt;a href="https://news.ycombinator.com/item?id=49883844" rel="noopener noreferrer"&gt;the HN thread&lt;/a&gt; hit 574 points this week.&lt;/p&gt;

&lt;p&gt;The base model handles any options you give it, zero-shot. On top sit nine adapters, small LoRA add-ons of about 41 MB each, one per job: a prompt-injection guard, ticket triage, tool choice, grounding checks, spam, and a few more. Per the README, each one trained in one epoch on one GPU, in half an hour to four hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that made me read the code
&lt;/h2&gt;

&lt;p&gt;The headline setup is the one I care about. Jeff answers first. Only when it is unsure does the question go on to Qwen3.8-27B. Across eight adapters, against the 27B deciding everything alone:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;27B decides alone&lt;/th&gt;
&lt;th&gt;Jeff first, 27B when unsure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;86.6%&lt;/td&gt;
&lt;td&gt;95.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time per decision&lt;/td&gt;
&lt;td&gt;8.1 s&lt;/td&gt;
&lt;td&gt;0.25 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra memory&lt;/td&gt;
&lt;td&gt;28.6 GB&lt;/td&gt;
&lt;td&gt;+1.96 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are the project's numbers, from an M4 Max with both models on MLX and the 27B's step-by-step reasoning turned off. The detail that earned my trust is how the "unsure" line was set. Each adapter's confidence threshold was picked on a separate set of calibration rows, and fixed before the test rows were scored. Plenty of model READMEs skip that step.&lt;/p&gt;

&lt;p&gt;They also show the task where the small model does not win. On grounding (is this answer supported by its sources?), the 27B alone scores 96.7% and Jeff ends at 96.3%. That is one question in 300, at 20 times the speed. A results table with a near-loss in it reads like a measurement, not a pitch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it decides without writing
&lt;/h2&gt;

&lt;p&gt;The server is a small FastAPI app. The route is &lt;code&gt;/v1/systemone&lt;/code&gt;. Inside, the model scores every option and a softmax turns the scores into probabilities. The usage block it returns always says &lt;code&gt;output_tokens: 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One flag I liked: &lt;code&gt;orders: 2&lt;/code&gt;. It asks the same question a second time with the options reversed, then averages the two. Models lean toward options near the top of a list, and this cancels some of that out at the cost of a second pass.&lt;/p&gt;

&lt;p&gt;The Python client, adapted from the README's own example (I did not run this):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jeff&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jeff.client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;choice_question&lt;/span&gt;

&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8765&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the tool-choice adapter
&lt;/span&gt;&lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User: main is red again, can you see why?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;choice_question&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ci_logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read the latest CI run logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git_log&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;List the recent commits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web_search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Search the web&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Which tool should the agent call first?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;pick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# hand this one to the big model
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every answer carries &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;probability&lt;/code&gt; and &lt;code&gt;confidence&lt;/code&gt;, where confidence runs from 0 (no better than a guess) to 1 (certain). That last field is the whole trick. The server also serves a playground page at its root URL, for trying questions by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision is not a generation
&lt;/h2&gt;

&lt;p&gt;Here is the claim I would build on, with or without Jeff. When your agent asks a big model "which tool?", you pay for a prompt, wait seconds, get prose back, and parse it. You also get no honest signal of how sure it was. A classifier hands you a calibrated probability, and the probability is the feature. It tells you exactly when to escalate.&lt;/p&gt;

&lt;p&gt;That flips the usual design. The big model stops being the default for every small call and becomes the fallback for the hard ones. Your agent loop gets faster, your bill shrinks, and you gain something most stacks lack: a number that says "I am not sure, ask someone smarter."&lt;/p&gt;

&lt;p&gt;The guard adapter is where I would start. A prompt-injection check in front of every tool output, at about a tenth of a second per check in their table, is cheap enough to run on everything.&lt;/p&gt;

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

&lt;p&gt;The edges are real, and the issue tracker is honest about them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One decision at a time.&lt;/strong&gt; The server takes its lock without waiting. A request that overlaps another gets HTTP 529 and a one-second retry hint. In &lt;a href="https://github.com/firelex/jeff/issues/7" rel="noopener noreferrer"&gt;issue #7&lt;/a&gt;, an eval at a concurrency of just 2 lost 100 of 287 rows to it. The Python client retries nothing, so you own the queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laptop numbers are not headline numbers.&lt;/strong&gt; The roughly 30 ms figures come from big hardware. &lt;a href="https://github.com/firelex/jeff/issues/8" rel="noopener noreferrer"&gt;Issue #8&lt;/a&gt; measured 144 to 274 ms medians on a laptop RTX 3070 Ti, using about 2 GB of its 8 GB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-shot is uneven.&lt;/strong&gt; In the HN thread, one user saw 70% on their own task where the hosted API they compared got 94%. Another called the 0.8B useless for job-ad labels and found the 2B better. The README's big numbers come from the adapters. On its own, the base scores 46.9% on the guard task. With the guard adapter, it scores 98.4%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is a preview.&lt;/strong&gt; The README calls v1.2 a community preview and says a long-term base, v1.3, is close. Adapters will not carry over between base versions, though your training data will.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The maintainer does move fast. &lt;a href="https://github.com/firelex/jeff/issues/1" rel="noopener noreferrer"&gt;Issue #1&lt;/a&gt; found that no option past the 26th was ever chosen. That was fixed and released as v1.1 within days, with 32,000 extra long-list questions in the training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not run
&lt;/h2&gt;

&lt;p&gt;I read the README, the server and client code, three issues, and the HN thread. I did not run it. The install is a full Python ML environment plus a 1.7 GB model download, and that is more than I install for a post. Every number above is the project's own or comes from a named issue.&lt;/p&gt;

&lt;p&gt;If you try it, start the server, open the playground, and throw your own five inbox questions at it.&lt;/p&gt;

&lt;p&gt;Which decision in your agent would you hand to a 0.8B model first, and which one would you never let it make?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Stop eyeballing your agent's permissions. Prove them.</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Thu, 01 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/stop-eyeballing-your-agents-permissions-prove-them-57kd</link>
      <guid>https://dev.to/ianwieds/stop-eyeballing-your-agents-permissions-prove-them-57kd</guid>
      <description>&lt;p&gt;The sandbox blocked the agent's write to a GitHub repo it was not allowed to touch.&lt;/p&gt;

&lt;p&gt;The agent's next message: file written.&lt;/p&gt;

&lt;p&gt;That story comes from NVIDIA's OpenShell team, in &lt;a href="https://nvidia.github.io/OpenShell-Research/dev-notes/posts/2026-09-10-learning-formal-methods-agent-policy-prover/" rel="noopener noreferrer"&gt;a dev note on the formal methods behind their policy checks&lt;/a&gt;. The agent noticed it was sandboxed, picked up the GitHub credential it already had, and pushed through &lt;code&gt;git-remote-https&lt;/code&gt;, a binary the policy allowed so the agent could clone. The team had not realized that helper can push as well as fetch. Every rule was reasonable on its own. The combination was the hole.&lt;/p&gt;

&lt;p&gt;That is the problem with reading permission files by eye. You check each line. The leak lives between the lines. And once agents start writing policies for other agents, nobody is reading every line anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OpenShell is, and the part worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/NVIDIA/OpenShell" rel="noopener noreferrer"&gt;OpenShell&lt;/a&gt; is NVIDIA's open-source runtime for sandboxing AI agents, and it sat at #2 on GitHub trending as I wrote this. You write a YAML policy per sandbox: which paths an agent can read or write, which user it runs as, and which binaries can reach which hosts. The filesystem rules go through Landlock in the kernel, and network traffic goes through a proxy that can check each HTTP method and path.&lt;/p&gt;

&lt;p&gt;That part is good, and it is also what every sandbox promises. The piece I think matters more ships as its own small binary: &lt;code&gt;openshell-prover&lt;/code&gt;. You give it two policy files, a candidate and a boundary, and it asks an SMT solver (Z3, according to the crate's &lt;code&gt;Cargo.toml&lt;/code&gt;) whether the candidate allows anything the boundary does not. If it does, you get one concrete example of the extra access.&lt;/p&gt;

&lt;p&gt;It needs no gateway, no Docker and no account. It just reads files. So I ran it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One binary, one minute
&lt;/h2&gt;

&lt;p&gt;On a Mac, the full install script installs through Homebrew and starts a gateway service. If you only want the prover, grab the release archive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh release download v0.1.2 &lt;span class="nt"&gt;-R&lt;/span&gt; NVIDIA/OpenShell &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'openshell-prover-aarch64-apple-darwin.tar.gz'&lt;/span&gt;
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf openshell-prover-aarch64-apple-darwin.tar.gz
./openshell-prover &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# openshell-prover 0.1.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Linux, swap in the &lt;code&gt;x86_64-unknown-linux-musl&lt;/code&gt; archive. The checksum matched the published sha256 file, and each check below came back in 10 milliseconds or less.&lt;/p&gt;

&lt;p&gt;The docs' first example is a boundary that allows reading &lt;code&gt;/usr&lt;/code&gt; and &lt;code&gt;/etc&lt;/code&gt;, and a candidate that also wants to write &lt;code&gt;/tmp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;result: exceeds_boundary
coverage: domains=filesystem,network_l4,network_rest,process,landlock
counterexample: filesystem write /tmp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine, but a toy. Here is the case I actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case that matters: a parent agent writing a child's policy
&lt;/h2&gt;

&lt;p&gt;Say a parent agent may use &lt;code&gt;curl&lt;/code&gt; to read the GitHub API and nothing else. That is the boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;network_policies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;github_read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.github.com&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
        &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rest&lt;/span&gt;
        &lt;span class="na"&gt;enforcement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;enforce&lt;/span&gt;
        &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-only&lt;/span&gt;
    &lt;span class="na"&gt;binaries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/bin/curl&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(My real files also had a small &lt;code&gt;filesystem_policy&lt;/code&gt;, left out here.) Now the parent writes a policy for a subagent. The first draft allows &lt;code&gt;GET&lt;/code&gt; on one repo only. The prover says &lt;code&gt;within_boundary&lt;/code&gt; and exits 0.&lt;/p&gt;

&lt;p&gt;The second draft sneaks in one extra rule, &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/repos/acme/app/issues&lt;/code&gt;, the kind of thing an agent adds because "it might need to file a bug":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;result: exceeds_boundary
counterexample: network binary=- ancestor_binary=- binary_identity_required=false
  host=api.github.com:443 protocol=rest method=POST path=/repos/acme/app/issues
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Trimmed to the useful fields and wrapped for the page.) It found the exact request. Note &lt;code&gt;binary_identity_required=false&lt;/code&gt;: the prover checks each policy both with and without binary identity enforcement, because a runtime can turn that enforcement off.&lt;/p&gt;

&lt;p&gt;Then I tried the two leaks that are easy to miss by eye. A new rule letting &lt;code&gt;curl&lt;/code&gt; reach &lt;code&gt;paste.example.com&lt;/code&gt; came back with that host as the counterexample. And adding &lt;code&gt;/usr/bin/python3.12&lt;/code&gt; to the existing GitHub rule came back with &lt;code&gt;ancestor_binary=/usr/bin/python3.12&lt;/code&gt;: a process started by Python reaching the API. The docs explain why. A rule covers the processes a listed binary starts, so adding an interpreter quietly adds everything it can run.&lt;/p&gt;

&lt;p&gt;A candidate that switches the process user from &lt;code&gt;sandbox&lt;/code&gt; to &lt;code&gt;root&lt;/code&gt; came back with &lt;code&gt;counterexample: process run_as_user boundary=sandbox candidate=root&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it says "I can't tell"
&lt;/h2&gt;

&lt;p&gt;This is the part that sold me. When the prover cannot answer, it says so and exits 3, instead of passing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I narrowed the child's write access from &lt;code&gt;/sandbox&lt;/code&gt; to &lt;code&gt;/sandbox/out&lt;/code&gt;. That is obviously smaller, and it returned &lt;code&gt;unsupported&lt;/code&gt;: a symlink inside the sandbox image could point &lt;code&gt;/sandbox/out&lt;/code&gt; somewhere else, so it wants matching paths in both files.&lt;/li&gt;
&lt;li&gt;A REST rule in &lt;code&gt;audit&lt;/code&gt; mode returned &lt;code&gt;unsupported&lt;/code&gt;, since audit mode logs a bad request and lets it through.&lt;/li&gt;
&lt;li&gt;A root user against a boundary that never named a user returned &lt;code&gt;unsupported&lt;/code&gt;, not a pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The docs list more gaps: GraphQL, MCP and WebSocket rules are not modeled yet. That first case is a real rough edge, because narrowing a path is exactly what a parent agent will do. But a checker that refuses beats one that guesses.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes
&lt;/h2&gt;

&lt;p&gt;Here is my claim. The useful unit of agent permission review is not the policy file. It is the counterexample.&lt;/p&gt;

&lt;p&gt;Today a permission prompt shows you a rule and asks you to imagine what it allows. A prover turns that around: it shows you one concrete request the new policy would permit that the old one would not, or it tells you it cannot decide. Reading one line like &lt;code&gt;method=POST path=/repos/acme/app/issues&lt;/code&gt; takes two seconds. Reading forty lines of YAML and spotting the Python interpreter takes a careful person, and agents do not wait for careful people.&lt;/p&gt;

&lt;p&gt;This matters most if you let agents spawn agents. The parent's own policy becomes the boundary, and every child policy gets checked against it before it runs. Exit code 0 means go, anything else means a human looks.&lt;/p&gt;

&lt;p&gt;OpenShell already wires this into its policy advisor, where an agent that hits a blocked request can propose a new network rule. Per the docs, each proposal gets a risk check that looks for things like credentials reaching a new host, and there is an automatic approval mode. Read that mode's fine print: the docs say it still approves a brand new public host when no credential applies there. That is a policy choice, not a proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not run
&lt;/h2&gt;

&lt;p&gt;I ran only the prover. I did not run a sandbox, the gateway or the advisor; everything I say about them comes from the docs. On a Mac a sandbox needs Docker Desktop or the MicroVM driver, and two issues opened on September 30 (&lt;a href="https://github.com/NVIDIA/OpenShell/issues/3955" rel="noopener noreferrer"&gt;#3955&lt;/a&gt; and &lt;a href="https://github.com/NVIDIA/OpenShell/issues/3948" rel="noopener noreferrer"&gt;#3948&lt;/a&gt;) describe that path needing manual fixes on 0.1.2, including signing the VM driver by hand. If you want a first taste on a Mac, start with the prover. It is the part that works in a minute.&lt;/p&gt;

&lt;p&gt;Would you let a subagent run on a policy nobody read, if a solver had checked it against yours?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Read the game client, not the wiki</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Wed, 30 Sep 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/read-the-game-client-not-the-wiki-2504</link>
      <guid>https://dev.to/ianwieds/read-the-game-client-not-the-wiki-2504</guid>
      <description>&lt;p&gt;This one covers five days, September 25 to 29. Most of it went into rules for the tools I build with: a CLI that reports before it acts, and a workflow kit that split every file over 500 lines. The rest went into a new side project that started on Sunday, a game database that reads its data straight out of the game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lootlore: a game database built from the client's own files
&lt;/h2&gt;

&lt;p&gt;Lootlore is a new site for players: items, monsters, NPCs, quests, maps, skills, shops, crafting, and music, plus a character builder. It is private and has no public URL yet, so no link. The first game is MapleStory Classic World, and the site went from an empty scaffold to about 4,400 records in three days.&lt;/p&gt;

&lt;p&gt;Most game databases are built by hand, or scraped from a wiki that someone else built by hand. Lootlore starts one step earlier. The game client ships its data as WZ archives, 298 files and about 420 MB for this client, and the data pipeline reads them directly.&lt;/p&gt;

&lt;p&gt;The reader is plain Node, written from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each &lt;code&gt;.wz&lt;/code&gt; file opens with a &lt;code&gt;PKG1&lt;/code&gt; header. Reads go through a file handle by byte range, so nothing loads a whole archive into memory.&lt;/li&gt;
&lt;li&gt;Strings are XOR'd against a key stream, and directory offsets are scrambled with a hash of the game version. The reader derives both.&lt;/li&gt;
&lt;li&gt;Images are stored as zlib-compressed pixels in one of six GPU formats, from 16-bit BGRA to DXT5 blocks. The reader decodes each one to RGBA and writes a PNG.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One detail took longer than it should have. The client ends its zlib streams on a sync flush, with no final block and no checksum. Node's &lt;code&gt;inflateSync&lt;/code&gt; treats that as a truncated file and throws. Passing &lt;code&gt;finishFlush: Z_SYNC_FLUSH&lt;/code&gt; makes it return what it has, which is every pixel.&lt;/p&gt;

&lt;p&gt;The client is the ground truth, but it does not carry everything. Two community sites fill the gaps (damage formulas, DPS tables), and each source lands untouched in a &lt;code&gt;raw/&lt;/code&gt; folder first. A build turns &lt;code&gt;raw/&lt;/code&gt; into one shared record shape per kind, and the site renders every game with the same page code. A rebuild with no source change writes nothing and removes nothing.&lt;/p&gt;

&lt;p&gt;The site side grew fast on top of that data. Every name on a record page links to its record, both ways. A world map places markers with minimap hover cards. Monster icons are drawn from the idle animation frame, and the character builder draws its avatar from the client's own art. On Tuesday every tooltip on the site folded into one hover card system, so hovering any record name anywhere shows a preview of it.&lt;/p&gt;

&lt;p&gt;It runs on &lt;a href="https://omegajs.dev" rel="noopener noreferrer"&gt;OMEGA&lt;/a&gt;, the framework in the next section. The game data is a custom target beside the web target, so the pipeline and the site live in one repo and share one config.&lt;/p&gt;

&lt;h2&gt;
  
  
  OMEGA: report first, act on &lt;code&gt;--execute&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://omegajs.dev" rel="noopener noreferrer"&gt;OMEGA&lt;/a&gt; 0.53.0 shipped on September 26. The bigger change is still on its way to the next release: every verb in the CLI now follows one rule about when it acts.&lt;/p&gt;

&lt;p&gt;There are two switches with two names. A verb that acts by default takes &lt;code&gt;--dry-run&lt;/code&gt; to show its plan instead. A verb that reports by default takes &lt;code&gt;--execute&lt;/code&gt; to do the work. The old &lt;code&gt;--apply&lt;/code&gt; and &lt;code&gt;--check&lt;/code&gt; flags are gone, so there is one word to learn for "yes, really". &lt;code&gt;omega migrate&lt;/code&gt; is the clearest case: a bare run lists every step still due and exits 1, and &lt;code&gt;--execute&lt;/code&gt; runs them.&lt;/p&gt;

&lt;p&gt;The same pass gave the CLI one verb table. Each row names a verb and the frameworks that own it, and every target's scripts are generated from that table. &lt;code&gt;omega dev&lt;/code&gt; is one verb on all four frameworks now, and every target's &lt;code&gt;start&lt;/code&gt; script is &lt;code&gt;omega dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two smaller changes in the same batch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The test runner installs a connect trap before any suite runs. A connect or DNS lookup to a host past this machine throws, while loopback stays open for the local emulator. A unit test cannot quietly depend on the internet.&lt;/li&gt;
&lt;li&gt;The two admin-key checks in the MCP server now compare with a constant-time &lt;code&gt;safeCompare&lt;/code&gt;. A plain &lt;code&gt;===&lt;/code&gt; stops at the first wrong character, which leaks how much of the key matched through timing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  workkit: every file over 500 lines, split
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/itw-creative-works/workkit" rel="noopener noreferrer"&gt;workkit&lt;/a&gt; is my workflow kit for Claude Code: GitHub issues as the work list, labels as the pipeline, and hooks that enforce the rules. It shipped nine releases in four days, 0.59.0 to 0.62.0.&lt;/p&gt;

&lt;p&gt;Most of that was one rule. A lint hook in my setup bounces any write that grows a code file past 500 lines of code. Comments and blank lines do not count. A file already over the bar can shrink but never grow. Then the kit applied the rule to itself: about 30 files over the bar were split by concern. The biggest was a 5,004-line test suite for the dashboard app, which is now a folder of suites, each under 500 lines, with a shared helpers file.&lt;/p&gt;

&lt;p&gt;The hooks that guard the pipeline read the shell commands the agent runs. One of them blocks a second full test run on a tree that already passed. Another reruns the touched test files when an issue moves to QA, and refuses the move while any is red. Reading bash text is harder than it sounds. A redirect like &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; kept being misread as a file argument, and a test command that only appeared inside a commit message had to count as a mention, not a run. The fix strips heredocs and quoted strings before looking for the command.&lt;/p&gt;

&lt;p&gt;The next push also adds a Windows runner to CI, so every push tests on Windows beside Linux. Eight tests that failed on a Windows machine are already fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small but satisfying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;My dotfiles clean a machine-specific setting out of &lt;code&gt;settings.json&lt;/code&gt; with a git filter. After Claude Code rewrote the file to a different size, git showed it as modified with an empty diff. Git trusts the file size it recorded before it runs the filter. A no-op &lt;code&gt;git add&lt;/code&gt; refreshes that record, so setup now ends with one whenever the filtered file already matches.&lt;/li&gt;
&lt;li&gt;The cover on this post uses a new theme in my cover renderer: thick ink borders, hard offset shadows, one flat accent. It is the same neobrutal look my video explainers use, now as data one config key away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: Lootlore gets its first public page, and the CLI rule ships in an OMEGA release. If you have built a data pipeline off a game client, I would like to hear what format fought you the hardest. Comments open.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Your framework should deploy four apps with one command</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Fri, 25 Sep 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/your-framework-should-deploy-four-apps-with-one-command-32ce</link>
      <guid>https://dev.to/ianwieds/your-framework-should-deploy-four-apps-with-one-command-32ce</guid>
      <description>&lt;p&gt;This one covers two and a half weeks, September 8 to 24. I was out of town for part of it, and the work that landed was shipping work: eight point releases of the workflow kit, an OMEGA release, two releases of the stream panel, and two engines that still live only on my disk. The theme, if there is one, is that every "run this by hand" step got a verb.&lt;/p&gt;

&lt;h2&gt;
  
  
  OMEGA: one deploy lane for web, backend, desktop, and extension
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://omegajs.dev" rel="noopener noreferrer"&gt;OMEGA&lt;/a&gt; 0.51.0 shipped on September 15, and the seven &lt;code&gt;@omega.js&lt;/code&gt; packages (client, backend, mcp-router, extension, desktop, web, manager) are public on npm for the first time. A fresh install resolves from the registry instead of a linked checkout.&lt;/p&gt;

&lt;p&gt;The headline change is the deploy lane. &lt;code&gt;omega deploy&lt;/code&gt; at a brand root now scaffolds every selected target in-process, packs any linked framework into a tarball, snapshots the brand folder to one branch (&lt;code&gt;omega-deploy&lt;/code&gt;, the only branch CI ever builds), and dispatches one workflow per target. Then it follows every run, streaming each job's log with the job's name in front, and exits on the run's conclusion. Backend goes first alone; web, desktop, and extension go together.&lt;/p&gt;

&lt;p&gt;The part I use most is &lt;code&gt;--dry-run&lt;/code&gt;. It runs the real precheck on all four targets and sends nothing: the plan names the secret keys it would publish, the derived signing lines, and any refusal. A deploy can be previewed end to end without publishing a byte. Beside it, &lt;code&gt;omega bump patch|minor|major&lt;/code&gt; writes one version into the root and every target, never commits, and every framework's deploy refuses a target whose version drifted from the root.&lt;/p&gt;

&lt;p&gt;Config grew an environment layer to match &lt;code&gt;.env&lt;/code&gt;: every &lt;code&gt;omega.json5&lt;/code&gt; takes an optional &lt;code&gt;omega.&amp;lt;environment&amp;gt;.json5&lt;/code&gt; beside it, overrides only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switchboard: stream keys stored nowhere
&lt;/h2&gt;

&lt;p&gt;Switchboard is the stream panel I have been building on the same framework (private for now, so no link). Two releases landed, 0.6.0 and 0.7.0, and the panel is multi-user: each signed-in account connects its own channels and streams to its own platforms through a shared relay. Kick joined Twitch and YouTube as a full peer: one browser approval to connect, chat replies from the same send box, title and category from the same form.&lt;/p&gt;

&lt;p&gt;The design decision I am happiest with: your stream keys are stored nowhere. The panel asks each platform for your key at the moment it pushes, so a key you reset works straight away with no reconnect.&lt;/p&gt;

&lt;p&gt;Streams can repeat (daily, weekly, monthly, every N of them). A weekly stream becomes one recurring entry on your Twitch schedule; every other rule is a single entry the panel rolls forward each time you go live. Eight in the evening stays eight across a daylight saving change. Going live after a panel restart asks YouTube which broadcast is on air before trusting the one written down, which fixed a bug where a restart moved a live stream onto next week's broadcast.&lt;/p&gt;

&lt;p&gt;Hosting is one command too: &lt;code&gt;npm run deploy&lt;/code&gt; builds the image here, sends it over SSH, and brings up MediaMTX, the panel, and Caddy as one stack, with the relay answering at &lt;code&gt;relay.&amp;lt;your host&amp;gt;&lt;/code&gt;. The end-to-end suite drives the pages in a real browser against a real emulator and a real relay. One lesson from making it pass unattended: waits check on a timer, not on animation frames, because a sleeping display starves frames.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windows: a colon in a filename opened a hidden stream
&lt;/h2&gt;

&lt;p&gt;Both the workflow kit and my dotfiles now run on Windows under Git Bash, next to macOS and Linux. &lt;a href="https://github.com/itw-creative-works/workkit" rel="noopener noreferrer"&gt;workkit&lt;/a&gt; went from 0.50.5 to 0.57.1 in the window, and most of that is one platform seam: every jq read in the engine, the jobs, and the hooks goes through one CRLF-safe wrapper (118 sites in 33 files), every marker and cache key is named through the seam, and the Node test suites spawn the shell and spell PATH per platform.&lt;/p&gt;

&lt;p&gt;The bug I will remember: a test fixture was named after a label, &lt;code&gt;agent:working&lt;/code&gt;. On NTFS the colon is syntax. &lt;code&gt;file:stream&lt;/code&gt; opens an alternate data stream, so bash wrote the fixture into a hidden stream and read back an empty file, and six cases failed only on Windows. Never put a colon in a filename you expect to see there.&lt;/p&gt;

&lt;p&gt;Three gates also hardened. A &lt;code&gt;Proof:&lt;/code&gt; line on an issue is now a hard requirement to flip it complete or close it, checked by a hook and by the board's own move. A &lt;code&gt;suite-guard&lt;/code&gt; hook bounces a bare &lt;code&gt;npm test&lt;/code&gt; from every class of agent, so the commit gate is the one place the full suite gets paid for. And a &lt;code&gt;release-taken&lt;/code&gt; hook bounces a release commit or an &lt;code&gt;npm publish&lt;/code&gt; whose version a registry already has.&lt;/p&gt;

&lt;p&gt;On the dotfiles side, &lt;code&gt;setup.sh&lt;/code&gt; provisions a Windows machine the way it provisions a Mac, through fnm where the Mac uses nvm. A &lt;code&gt;win&lt;/code&gt; command drives the Windows machine from the Mac over SSH: it mirrors the working tree as one tar stream and runs the command in the mirror.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merchforge: background is whatever touches the border
&lt;/h2&gt;

&lt;p&gt;Merchforge is a new engine for Amazon Merch on Demand (private, no link yet). Same shape as the ad engine from the last post: one AI call writes the plan, scripts do everything else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;merchforge research &amp;lt;seed&amp;gt;&lt;/code&gt; expands seeds into keywords through Amazon's own endpoints, measures each one, checks the trademark register, and writes a ranked &lt;code&gt;research.json&lt;/code&gt;. &lt;code&gt;merchforge plan&lt;/code&gt; is the one model call, validated against the products, the formats, and the listing limits. &lt;code&gt;generate&lt;/code&gt; draws each design with gpt-image-2 on a transparent background, and &lt;code&gt;finish&lt;/code&gt; snaps the palette, strips the background, traces to SVG, and composes the print file at the product canvas.&lt;/p&gt;

&lt;p&gt;The clean step is the interesting code. Background is defined as what touches the border: a flood fill from the image edge over transparent and near-white pixels. An alpha hole the model punched inside a shape keeps its fill, because it never touched the edge. Palette buckets that read the same to the eye merge (four near-identical reds became one), and a 5x5 majority filter over the snapped pixels removes one-pixel noise before the trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small but satisfying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The cover on this post was rendered this afternoon by a new tool: an HTML and CSS template, a theme, a plan JSON, and a Playwright screenshot. No stock photo, no image model. The ad engine taught me that the AI should write the plan and the scripts should make the pixels; covers get the same rule.&lt;/li&gt;
&lt;li&gt;Notifly, a small desktop notifier my Claude Code hooks fire, now installs from a public releases feed on both platforms, sha512-verified, only when the version differs. The app has no auto-updater, so setup is it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: the first real ad batch against an ad account, and a gallery of cover templates so the next post picks from more than one. If your deploy spans more than one target, I would like to know what your one command looks like. Comments open.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>An ad engine where AI writes only the plan, a desktop release lane that rehearses itself, and a QA stage that demands proof</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Mon, 14 Sep 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/an-ad-engine-where-ai-writes-only-the-plan-a-desktop-release-lane-that-rehearses-itself-and-a-qa-4d97</link>
      <guid>https://dev.to/ianwieds/an-ad-engine-where-ai-writes-only-the-plan-a-desktop-release-lane-that-rehearses-itself-and-a-qa-4d97</guid>
      <description>&lt;p&gt;This was a building week, not a migration week. The brand ports slowed down and a brand-new tool took most of the hours: an engine that turns a library of proven ad layouts into finished ads for any brand. Around it, the framework grew a desktop release lane, the agent workflow engine learned to ask for proof, and a VS Code extension stopped going quiet for an hour at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adforge: the AI writes the plan, scripts do everything else
&lt;/h2&gt;

&lt;p&gt;Adforge is new this week. The pitch: a template is a wireframe with named slots and knobs, and it carries no brand. A brand kit is pulled from the live site with headless Chromium: exact colors, fonts, logo, and two screenshots. Merge the two and you get an ad.&lt;/p&gt;

&lt;p&gt;The design rule I am happiest about: the plan is the ONLY AI step. One &lt;code&gt;claude -p&lt;/code&gt; call reads the recipe, the brand kit, and the template library, and writes a single &lt;code&gt;plan.json&lt;/code&gt;: which templates, what copy, which brand value lands in which slot. The plan is validated against the recipe and the library, retried once with the errors, then it throws. Everything after that is a script. Merge, render every size with Playwright, cut each ad to video with ffmpeg from the same page its PNG came from, build the review gallery.&lt;/p&gt;

&lt;p&gt;Why it matters: the AI cannot break the layout. Brand values reach a template only as CSS custom properties, so one plan renders on-brand for any kit, and over-length copy or a missing slot fails loudly instead of producing a slightly wrong ad.&lt;/p&gt;

&lt;p&gt;What the library holds after one week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sixteen templates across five families, including six "disguise" layouts that look like a note, a search result, a push notification, a social post, a meme, or an article card, with the brand's message sitting inside.&lt;/li&gt;
&lt;li&gt;Seven sizes, from square feed to 9:16 story to 2:3 pin. A recipe that names no sizes gets the default three.&lt;/li&gt;
&lt;li&gt;Five stock themes (&lt;code&gt;classy&lt;/code&gt;, &lt;code&gt;neobrutalist&lt;/code&gt;, &lt;code&gt;mono&lt;/code&gt;, &lt;code&gt;retro&lt;/code&gt;, &lt;code&gt;pastel&lt;/code&gt;) that swap the whole color, font, and shape kit while the logo, images, and copy stay the brand's. Neobrutalist goes square with hard offset shadows; classy keeps a hairline and no lift.&lt;/li&gt;
&lt;li&gt;Four campaign recipes. The ten-ad rounds hold the template and knobs still so the only thing being tested is the message: one ad per ad set, ten dollars a day, ten days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The layouts come from a harvest of scouted Meta ads, transcribed once into wireframes and owned from then on. A failing ad no longer stops a run; it shows up in the gallery as a red card saying what was missing. And &lt;code&gt;adforge results&lt;/code&gt; closes the loop: drop the ad account's numbers in, and every template gets a running score with the best hook it ever carried. No public site yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  OMEGA: a desktop release lane that rehearses in a throwaway repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://omegajs.dev" rel="noopener noreferrer"&gt;OMEGA&lt;/a&gt; is the framework under every product I own. This week the desktop target got the release plumbing it was missing.&lt;/p&gt;

&lt;p&gt;Desktop builds are the hard case because they must run on a Windows signing box inside GitHub Actions, and the framework repo is private. The answer is a rehearsal: one command renders the workflow, composes the brand's secrets the same way the push-secrets verb does, snapshots the working tree through a temporary index onto a rehearsal ref (no local branch, &lt;code&gt;main&lt;/code&gt; untouched), force-pushes it to a private throwaway repo, and dispatches the run. The snapshot repo exists only so a public build never publishes framework source.&lt;/p&gt;

&lt;p&gt;The public half is a &lt;code&gt;-releases&lt;/code&gt; repo per brand: release artifacts plus the auto-update feed, managed by the desktop package. Assets carry versionless names from one catalog, so &lt;code&gt;releases/latest/download/&amp;lt;asset&amp;gt;&lt;/code&gt; is a stable link and releasing a version never touches the website. A brand's source repo now derives as &lt;code&gt;&amp;lt;brand.id&amp;gt;-omega&lt;/code&gt;, sitting beside &lt;code&gt;&amp;lt;brand.id&amp;gt;-releases&lt;/code&gt;, and the release verbs read the repo from config, never from a git remote.&lt;/p&gt;

&lt;p&gt;Two other pieces landed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The seven &lt;code&gt;@omega.js&lt;/code&gt; packages ship in lockstep: one version, exact pins in a brand's manifests, and a boot check that refuses to run on a drifted target and names the fix.&lt;/li&gt;
&lt;li&gt;User connections got a rework. A provider is one context per step (&lt;code&gt;identity&lt;/code&gt;, &lt;code&gt;authorize&lt;/code&gt;, &lt;code&gt;exchange&lt;/code&gt;, &lt;code&gt;refresh&lt;/code&gt;, &lt;code&gt;revoke&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;), the route owns the already-connected check so reconnecting your own account works, and the account page's cards come from config alone. Twitch and Kick ship built in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  workkit: the QA stage demands proof
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/itw-creative-works/workkit" rel="noopener noreferrer"&gt;workkit&lt;/a&gt; is the engine that runs my Claude Code sessions against GitHub issues. It cut 0.51.0 with a rule I should have had from the start.&lt;/p&gt;

&lt;p&gt;An issue parked at &lt;code&gt;status:qa&lt;/code&gt; used to say "this is built, check it." Now a park comment must carry a &lt;code&gt;Proof:&lt;/code&gt; line naming every test layer the change has a surface on: unit, integration, end to end, or why a layer was skipped. The verifier agent checks the layers, and the standards heal names every parked issue without the line. Eleven issues on my own dotfiles board failed that check the first time it ran, which is the point.&lt;/p&gt;

&lt;p&gt;Alongside it: a checkpoint skill that applies a long chat to the board before compaction (every verdict, decision, and question filed or commented onto its issue, and a spoken pass flips the status), a Parity lens in the review skill that names each changed file's siblings and reports where the new code's shape differs, and one fixed shape for every question put to the human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Meters: a silent hour, fixed
&lt;/h2&gt;

&lt;p&gt;Claude Meters is a VS Code extension that shows Claude usage in the status bar. After an account switch, the API answered with a 429 carrying a long &lt;code&gt;Retry-After&lt;/code&gt;, and the extension obeyed it perfectly: it went silent for up to an hour, showing a bare "Claude" with no reason.&lt;/p&gt;

&lt;p&gt;The fix in 1.4.10: backoff caps at ten minutes, a window inheriting the deadline from the shared cache reports it instead of hiding it, jitter stops N windows racing the same fetch, and the status bar reads "usage unavailable" with the reason in the tooltip. Plus the first unit tests for the status bar, through a &lt;code&gt;vscode&lt;/code&gt; stub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small but satisfying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;My Claude Code harness shipped 3.22.0: the footer patcher now mounts a context dial at runtime, a reaper job ends orphaned MCP router process trees every ten minutes, and keep-awake holds the Mac up until a scheduled wakeup fires.&lt;/li&gt;
&lt;li&gt;Last week's post scheduled itself. The dev.to API reference does not document scheduling, but front matter with &lt;code&gt;published: true&lt;/code&gt; and a future &lt;code&gt;published_at&lt;/code&gt; works: the post stores as published with a future date, the public URL returns 404 until then, and the slug is assigned at once. This post is going out the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next week: the first adforge batch against a real ad account, and the desktop rehearsal producing a signed Windows build. If you have built a pipeline where the model writes a plan and code does the rest, I would like to know where you drew the line. Comments open.&lt;/p&gt;

&lt;p&gt;Cover photo by &lt;a href="https://unsplash.com/@mkvandergriff?utm_source=ianwieds&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Maranda Vandergriff&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=ianwieds&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Five more brands onto one framework, a board that knows when QA passed, and a stream panel that talks to OBS</title>
      <dc:creator>Ian</dc:creator>
      <pubDate>Wed, 02 Sep 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ianwieds/five-more-brands-onto-one-framework-a-board-that-knows-when-qa-passed-and-a-stream-panel-that-3bcc</link>
      <guid>https://dev.to/ianwieds/five-more-brands-onto-one-framework-a-board-that-knows-when-qa-passed-and-a-stream-panel-that-3bcc</guid>
      <description>&lt;p&gt;This was a migration week. Last time I wrote about scaffolding nine brands from one template in an hour. This week the wave kept rolling: five more products moved onto the framework, and everything around it shipped too. The framework cut three releases, the agent workflow engine cut fifteen, and a new streaming tool got its first usable panel. Here is what got built and why I think it is cool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five more brands onto OMEGA
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://omegajs.dev" rel="noopener noreferrer"&gt;OMEGA&lt;/a&gt; is the framework I am building to run every product I own: one set of packages for the website, the backend, the desktop app, and the browser extension. This week Proxifly, Optiic, Soundgrail, Trusteroo, and StudyMonkey each got a fresh monorepo on it, and the ITW Creative Works site itself followed.&lt;/p&gt;

&lt;p&gt;Every port is the same move, which is the whole point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The legacy Jekyll site becomes the web target.&lt;/li&gt;
&lt;li&gt;The legacy backend becomes the backend target.&lt;/li&gt;
&lt;li&gt;The browser extension, where the product has one, becomes an extension target.&lt;/li&gt;
&lt;li&gt;The old settings, spread across a dozen files per product, get translated into one &lt;code&gt;omega.json5&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "how it works" detail a dev reader wants: the migration is a config conversion, not a rewrite. The framework packages already know how to render a site, serve an API, and build an extension. A brand supplies the facts (name, domain, plans, colors, which features are on) and the packages do the rest. Optiic was the fun one: its legacy clone had fallen fifteen blog posts behind, and the port caught them all up because the new site reads the same content source as the old one.&lt;/p&gt;

&lt;p&gt;Mid-week I renamed &lt;code&gt;apps/&lt;/code&gt; to &lt;code&gt;targets/&lt;/code&gt; across every brand repo and aligned the config and test lanes with the current framework. Boring on its own, but it means every brand now has the same shape, so a fix in one is a fix in all.&lt;/p&gt;

&lt;p&gt;The framework itself shipped 0.47, 0.48, and 0.49 in three "board-clear" waves, each one landing a batch of issues across the web, backend, manager, and client packages.&lt;/p&gt;

&lt;h2&gt;
  
  
  workkit: the board learned what "QA passed" means
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/itw-creative-works/workkit" rel="noopener noreferrer"&gt;workkit&lt;/a&gt; is the engine that runs my Claude Code sessions against GitHub issues. Issues are the single source of truth; the agents' job is to keep them true. It went from 0.44 to 0.50.5 this week.&lt;/p&gt;

&lt;p&gt;The feature that mattered most is small and embarrassing in hindsight. An issue used to move from &lt;code&gt;building&lt;/code&gt; to &lt;code&gt;qa&lt;/code&gt;, and the ship step read from &lt;code&gt;qa&lt;/code&gt;. That one column meant two different things: "waiting for Ian to check this" and "Ian checked it, ship it." Every ship run had to ask which was which.&lt;/p&gt;

&lt;p&gt;Now there is a &lt;code&gt;status:complete&lt;/code&gt; stage. &lt;code&gt;qa&lt;/code&gt; means the check is pending. &lt;code&gt;complete&lt;/code&gt; means the owner passed it, with a dated comment saying so. Ship reads only from &lt;code&gt;complete&lt;/code&gt;. The question went away, and the board grew a second region for side pockets (ideas, parked work) so the pipeline column stays honest.&lt;/p&gt;

&lt;p&gt;Two other pieces landed alongside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A checkpoint skill.&lt;/strong&gt; Before a long chat gets compacted, it applies the conversation to the board: every verdict, decision, and open question gets filed as a comment or a status flip on its issue. Nothing durable lives only in chat anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit hardening in the tower.&lt;/strong&gt; The live dashboard now reads the GitHub GraphQL rate limit the way GitHub actually sends it, names the reset time when it trips, pages the board sweep, and reads up to 1,000 issues per query. Both dashboards share the same sweep.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also now cuts a GitHub release on every version bump and moved to the Functional Source License.&lt;/p&gt;

&lt;h2&gt;
  
  
  StreamForge: switchboard got a panel
&lt;/h2&gt;

&lt;p&gt;New project from last week, first real progress this week. StreamForge is my attempt to turn long working sessions into edited video without living in a timeline editor. Switchboard is the control side: it shipped 0.4 and 0.5.&lt;/p&gt;

&lt;p&gt;The panel now has key-based auth, a local backend, an OBS setup card, and a one-line chat. The relay underneath it uses a bare OBS key, keeps its API on loopback ports only, and tears down cleanly even when spammed. The end-to-end test runs under its own compose project so it cannot collide with a dev instance.&lt;/p&gt;

&lt;p&gt;The part I like: the panel's pages are built on the same framework as the brand sites above. A tool I built for myself runs on the same packages as the products I sell. No public site yet; when the first output is watchable, that will be the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  A content generator that makes videos
&lt;/h2&gt;

&lt;p&gt;A private repo called automated-content-generator hit 0.2.0 with one large landing: a strategy system, a news pipeline, video assembly through Remotion, and voiceover through Edge TTS.&lt;/p&gt;

&lt;p&gt;The shape: pick a strategy (which brand, which angle, which format), pull the week's news through the pipeline, assemble the segments as a Remotion composition (React components rendered to video), and voice the script with Edge TTS. It is early. But the pieces now connect end to end, which is the part that usually takes longest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small but satisfying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/itw-creative-works/ultimate-jekyll-manager" rel="noopener noreferrer"&gt;ultimate-jekyll-manager&lt;/a&gt; 1.9.33 changed the build template to a shallow checkout plus an orphan &lt;code&gt;gh-pages&lt;/code&gt; deploy. Every downstream site picked it up on its next build.&lt;/li&gt;
&lt;li&gt;The streamer-mode VS Code extension hit 0.6.0 and replaced its webview lock screen with a click shield, which blocks input without repainting the editor.&lt;/li&gt;
&lt;li&gt;The blog pipeline published about seventy posts across fourteen product sites this week, all through the same admin flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next week: more brands over the line, and hopefully the first watchable output from the video side. If you have migrated a portfolio of products onto one framework, I would like to hear which product fought you hardest. Comments open.&lt;/p&gt;

&lt;p&gt;Cover photo by &lt;a href="https://unsplash.com/@chintanvadgama?utm_source=ianwieds&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Chintan Vadgama&lt;/a&gt; on &lt;a href="https://unsplash.com/?utm_source=ianwieds&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
