<?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: Ken Imoto</title>
    <description>The latest articles on DEV Community by Ken Imoto (@kenimo49).</description>
    <link>https://dev.to/kenimo49</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%2F3800250%2F275022f6-cba9-47e3-b69e-e8faf7675a0c.jpg</url>
      <title>DEV Community: Ken Imoto</title>
      <link>https://dev.to/kenimo49</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenimo49"/>
    <language>en</language>
    <item>
      <title>4 Vector DBs Compared: Pinecone, Weaviate, Qdrant, pgvector on Enterprise RAG</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 09 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/4-vector-dbs-compared-pinecone-weaviate-qdrant-pgvector-on-enterprise-rag-fb5</link>
      <guid>https://dev.to/kenimo49/4-vector-dbs-compared-pinecone-weaviate-qdrant-pgvector-on-enterprise-rag-fb5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0n3o7vboufk8y4h6g9r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0n3o7vboufk8y4h6g9r.png" alt="4 Vector DBs on the four axes that decide the bill: Pinecone, Weaviate, Qdrant, pgvector" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The enterprise RAG vendor selection meeting always starts in the wrong place.&lt;/p&gt;

&lt;p&gt;Someone pulls up a benchmark. The latency numbers go on the whiteboard. An hour later, the decision is made on p99 at 1M vectors, and nobody has asked what happens at the eighteen-month mark, when the index is 30M vectors, three teams depend on it, and the vendor just raised their per-query price.&lt;/p&gt;

&lt;p&gt;Four names come up in almost every one of those meetings: Pinecone, Weaviate, Qdrant, and pgvector. They are not an exhaustive list. They are the shortlist that falls out of "we need production scale, we need metadata filtering, and the person sitting here has to be able to operate it on Monday." This post compares them on the four axes that actually move that Monday-morning decision, based on each vendor's public documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10,000-foot view
&lt;/h2&gt;

&lt;p&gt;Before the axes, a single table to orient:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;Pinecone&lt;/th&gt;
&lt;th&gt;Weaviate&lt;/th&gt;
&lt;th&gt;Qdrant&lt;/th&gt;
&lt;th&gt;pgvector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Category&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed-only cloud&lt;/td&gt;
&lt;td&gt;OSS (self-host or cloud)&lt;/td&gt;
&lt;td&gt;OSS (self-host or cloud)&lt;/td&gt;
&lt;td&gt;PostgreSQL extension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Primary language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Opaque (managed)&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;C (PG extension)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Index algorithm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proprietary (no tuning)&lt;/td&gt;
&lt;td&gt;HNSW&lt;/td&gt;
&lt;td&gt;HNSW (multiple variants)&lt;/td&gt;
&lt;td&gt;HNSW + IVFFlat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pricing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per-pod or per-read/write&lt;/td&gt;
&lt;td&gt;Infra + optional cloud&lt;/td&gt;
&lt;td&gt;Infra + optional cloud&lt;/td&gt;
&lt;td&gt;PostgreSQL infra only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Default scale ceiling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Billions (managed)&lt;/td&gt;
&lt;td&gt;~10B (self-host)&lt;/td&gt;
&lt;td&gt;Billions (self-host)&lt;/td&gt;
&lt;td&gt;~5-10M before memory pressure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four choices, four different bets about where the operational pain will show up. Let's walk the axes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axis 1: Scaling model
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pinecone&lt;/strong&gt; is managed-only. You cannot self-host it. In exchange, the capacity planning problem turns into a dropdown: pick a pod tier, pay the bill. The ceiling is high (Pinecone's public docs describe deployments into the billions of vectors), but every scale knob is behind their control plane. When you hit a performance issue at 2 AM, your only lever is to open a support ticket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaviate&lt;/strong&gt; publishes both a self-hosted binary and a managed cloud. Self-hosted scales horizontally by sharding across nodes, and the public docs describe deployments up to roughly 10 billion vectors. The cost: you own the Kubernetes. The payoff: you can see the memory profile, add nodes, debug the thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qdrant&lt;/strong&gt; is OSS first, and the Qdrant Cloud managed tier is a second option. Self-hosting is a single Rust binary that runs on a few hundred MB of RAM for small indexes and scales up by adding shards. The operational surface is smaller than Weaviate's, which is partly why Qdrant has been quietly eating mindshare in the self-host crowd.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pgvector&lt;/strong&gt; is not a separate system. It is a PostgreSQL extension, which means your scaling model is your existing PostgreSQL scaling model. If you already run managed Postgres (RDS, Cloud SQL, Neon, Supabase), adding pgvector is a &lt;code&gt;CREATE EXTENSION&lt;/code&gt; away. That is also its limit: pgvector performs well up to a few million vectors, but above roughly 5-10M vectors the HNSW index starts to feel the memory-boundedness of a single-node Postgres, and you are back to sharding Postgres, which is nobody's favorite afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb.&lt;/strong&gt; If your projected 24-month vector count is under 5M, pgvector is almost always the cheapest answer. Over 50M, either Qdrant or Weaviate self-hosted will cost less than Pinecone. Between those, it depends on the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axis 2: Index algorithm (and what you're allowed to tune)
&lt;/h2&gt;

&lt;p&gt;Three of the four — Weaviate, Qdrant, and pgvector — use HNSW (Hierarchical Navigable Small World) as their primary ANN index. Pinecone runs its own proprietary stack (per Pinecone's docs, HNSW is not used). The differences are in which parameters you're allowed to touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pgvector&lt;/strong&gt; has had HNSW since version 0.5.0 (shipped 2023), with two main tuning parameters: &lt;code&gt;m&lt;/code&gt; (max connections per node) and &lt;code&gt;ef_construction&lt;/code&gt; (candidate list size during build). At query time you set &lt;code&gt;hnsw.ef_search&lt;/code&gt;. The public pgvector GitHub README documents all three. Simple, exposed, nothing hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qdrant&lt;/strong&gt;'s HNSW implementation is one of the fastest in the open benchmarks. It exposes &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;ef_construct&lt;/code&gt;, and &lt;code&gt;full_scan_threshold&lt;/code&gt;, and the engine does a lot of work behind the scenes to keep the index memory-mapped efficiently. Public benchmarks (the ANN Benchmarks project and the vendors' own) consistently put Qdrant in the top tier for recall-at-latency at 1M vectors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaviate&lt;/strong&gt; also uses HNSW with &lt;code&gt;efConstruction&lt;/code&gt; and &lt;code&gt;maxConnections&lt;/code&gt;, both documented in the public docs under "vector index parameters." Weaviate's differentiator is hybrid search: you can combine the vector score with BM25 keyword scores in the same query without wiring up a separate Elasticsearch, which matters for any RAG system where the question has named entities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pinecone&lt;/strong&gt; is the outlier. Its index is a proprietary black box — Pinecone's documentation describes algorithm selection as automatic, and parameters like &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;ef_construction&lt;/code&gt;, or &lt;code&gt;ef_search&lt;/code&gt; are not exposed because the underlying engine is not HNSW to begin with. You pick a pod type, you get Pinecone's choice of tradeoff. For most teams that is fine. For a team that just saw their p99 regress and wants to buy back latency by lowering a search-time knob, it is not.&lt;/p&gt;

&lt;p&gt;The implication is organizational, not technical. A team that likes knobs will outgrow Pinecone eventually. A team that wants the knobs invisible will outgrow self-hosted Qdrant eventually, in the opposite direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axis 3: Metadata filtering (pre vs post)
&lt;/h2&gt;

&lt;p&gt;This axis is where the actual RAG quality lives, and it is also where the vendors diverge hardest.&lt;/p&gt;

&lt;p&gt;Every production RAG query looks something like: "give me the 10 nearest chunks, but only from documents owned by &lt;code&gt;tenant_id=42&lt;/code&gt;, created after &lt;code&gt;2026-06-01&lt;/code&gt;, with &lt;code&gt;status=published&lt;/code&gt;." The vector search is the fast part. The filter is the part that decides whether the result is correct.&lt;/p&gt;

&lt;p&gt;Two design patterns exist:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post-filter.&lt;/strong&gt; Run the ANN search first, get top-K candidates, then apply the metadata filter. If the filter rejects most candidates, you get fewer than K results. Fix: fetch K×N and over-retrieve. This is what most naive systems do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-filter (filtered search).&lt;/strong&gt; Push the filter into the HNSW graph traversal itself. The graph walk skips nodes that fail the filter. You get exactly K results that match the filter.&lt;/p&gt;

&lt;p&gt;Qdrant's documentation is unusually explicit about this: its payload index supports filtered ANN where the filter is applied during graph traversal, not as a post-process. In practice, this means Qdrant degrades gracefully on highly-selective filters (&lt;code&gt;tenant_id=rare_customer&lt;/code&gt;) where a post-filter engine would miss results.&lt;/p&gt;

&lt;p&gt;Weaviate's documentation describes a similar approach with "filtered vector search" that integrates with its inverted index for the metadata side.&lt;/p&gt;

&lt;p&gt;Pinecone supports metadata filtering, and their docs describe it as pre-filtered search, though the specific internal strategy isn't exposed (per Axis 2).&lt;/p&gt;

&lt;p&gt;pgvector's situation is different: the filter is a WHERE clause in SQL, and the Postgres planner decides whether to use the HNSW index or a sequential scan. For narrow filters on indexed columns, Postgres can filter first and then do a brute-force vector scan over the small candidate set, which is often faster than HNSW over everything. For broad filters, it goes through the HNSW index. The developer doesn't pick the strategy; the query planner does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your RAG system has highly-selective tenant or permission filters, this axis matters more than latency&lt;/strong&gt;. On a 100M-vector index where each query is restricted to one tenant's 10,000 vectors, a pre-filter engine returns the right 10 results. A post-filter engine might return 2 or 3 and silently drop the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Axis 4: Pricing model
&lt;/h2&gt;

&lt;p&gt;The axis that gets undercounted in the first meeting and overweighted in the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pinecone&lt;/strong&gt; is pay-per-use (serverless) or pay-per-pod (classic). Public pricing for serverless is listed per million read units, per million write units, and per GB stored. On a RAG system that does 100K queries/day against a 10M-vector index, the monthly bill at the time of writing lands in the mid-four-figures USD depending on query complexity. Predictable. Linear. Not cheap at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weaviate Cloud&lt;/strong&gt; has a tiered pricing model (free sandbox, then paid tiers differentiated by SLA, replication, and dedicated-infrastructure guarantees). Self-hosted is infra cost only: your Kubernetes, your bandwidth, your on-call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qdrant Cloud&lt;/strong&gt; publishes per-cluster pricing by instance size. Self-hosted is infra cost only, same as Weaviate. Qdrant's single-binary footprint tends to make self-host cheaper than Weaviate's at small scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pgvector&lt;/strong&gt; has no license cost. The only cost is whatever Postgres is already costing you. If you have a managed Postgres, pgvector is approximately free. If you don't, pgvector isn't actually free; you just moved the vendor lock-in to the Postgres host.&lt;/p&gt;

&lt;p&gt;The honest comparison: at the 1-10M vector scale, pgvector on an existing Postgres is often 10-30× cheaper than managed Pinecone. At the 100M+ scale, operated Qdrant or Weaviate self-hosted usually beats Pinecone by 2-5×, but with operational overhead that is not free. The crossover depends on how expensive your SRE hours are. Those hours never cost what the capacity plan said they would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three decision paths
&lt;/h2&gt;

&lt;p&gt;With the four axes, a decision on vendor usually falls into one of three profiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SaaS startup, 1-10M vectors, no dedicated infra team.&lt;/strong&gt; Pinecone serverless or pgvector. Pinecone if your Postgres is already stressed and you want zero additional operational surface. pgvector if your Postgres has headroom and your team knows SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal RAG for a mid-size company, 10-100M vectors, Kubernetes team available.&lt;/strong&gt; Qdrant self-hosted. The ceiling is high enough, the operational surface is small enough, and the filtered-search behavior is correct on tenant-scoped queries. Weaviate if you need hybrid search without wiring Elasticsearch next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedded product (RAG-as-feature in a shipped app), unknown scale.&lt;/strong&gt; Start with pgvector if your product already has Postgres. Move to Qdrant if you cross 10M. Do not start with Pinecone; the per-query cost at the embedded-feature scale tends to dominate the unit economics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Milvus, Chroma, LanceDB
&lt;/h2&gt;

&lt;p&gt;Three honorable mentions that often come up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Milvus&lt;/strong&gt; is excellent at the billions-of-vectors scale and has enterprise deployments at that tier. For the 1M-100M range this post targets, it is more operational weight than Qdrant for similar performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chroma&lt;/strong&gt; is optimized for local development and small-scale deployments. Scales beyond a few million vectors are a stretch; this is a local-first system, not an enterprise one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LanceDB&lt;/strong&gt; has a strong story for vector-plus-analytics workloads sitting on object storage. Worth a look if your RAG system wants to query vectors and OLAP columns in the same query. For pure RAG, the four above are more battle-tested.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the book covers that this post doesn't
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://kenimoto.dev/books/context-engineering?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=4-vector-dbs-enterprise-rag" rel="noopener noreferrer"&gt;book this post is condensed from&lt;/a&gt; spends most of its length on the pieces that matter &lt;em&gt;before&lt;/em&gt; vendor selection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 3-axis evaluation framework for enterprise RAG (faithfulness 50%, relevancy 25%, correctness 25%)&lt;/li&gt;
&lt;li&gt;The security and access-control design patterns (RBAC, pre-filter on tenant, prompt-injection detection)&lt;/li&gt;
&lt;li&gt;The information-classification matrix for deciding which internal docs to even put in a RAG system in the first place&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vendor selection is downstream of all three. If you pick the vendor first and the evaluation framework second, you tend to end up with the vendor your evaluation framework can't honestly measure. The chapter walks the full ordering in Japanese; this post is the vendor-level summary.&lt;/p&gt;




&lt;p&gt;If you want the full version of the Context Engineering playbook (RAG, MCP, CLAUDE.md, Agentic RAG, with benchmarks), it's a book: &lt;a href="https://kenimoto.dev/books/context-engineering?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=4-vector-dbs-enterprise-rag" rel="noopener noreferrer"&gt;&lt;em&gt;Turning LLMs from Liars into Experts&lt;/em&gt;&lt;/a&gt;. Original benchmarks show a 4.6x quality gain across five context strategies, with 96 production-grade Python files published on GitHub.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>3 UI Slop Patterns in AI UI Generators: Purple Gradients, Inter Fonts, Uniform Cards</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Fri, 06 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/3-ui-slop-patterns-in-ai-ui-generators-purple-gradients-inter-fonts-uniform-cards-11l6</link>
      <guid>https://dev.to/kenimo49/3-ui-slop-patterns-in-ai-ui-generators-purple-gradients-inter-fonts-uniform-cards-11l6</guid>
      <description>&lt;p&gt;Open &lt;a href="https://v0.app" rel="noopener noreferrer"&gt;v0&lt;/a&gt;, Lovable, &lt;a href="https://bolt.new" rel="noopener noreferrer"&gt;Bolt.new&lt;/a&gt;, or Claude Artifacts, and ask any of them for a modern SaaS landing page. They converge. Four independent tools produce pages that look like they came from one studio, and that studio really likes purple.&lt;/p&gt;

&lt;p&gt;The convergence comes down to three patterns. Once you can name them, you can design around them. Until then, anyone who has seen ten AI-generated pages will spot yours in about two seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "modern" means the same thing to every generator
&lt;/h2&gt;

&lt;p&gt;Before the three patterns, the mechanism.&lt;/p&gt;

&lt;p&gt;These tools don't invent styles. They sample from a training corpus of what the open web calls "modern." The open web, between 2020 and 2024, decided "modern" meant three specific things, and now every generator reproduces them because the gradient-descent floor underneath every one of them points to the same local minimum.&lt;/p&gt;

&lt;p&gt;v0 rebranded from &lt;code&gt;v0.dev&lt;/code&gt; to &lt;code&gt;v0.app&lt;/code&gt; in August 2025 and shipped full-stack. Lovable writes the backend too now. Bolt expanded its stack. Claude Artifacts generates React inside the chat window. The engines got smarter. The aesthetic did not move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: Purple Gradient Syndrome
&lt;/h2&gt;

&lt;p&gt;Prompt any 2026 generator for a landing page hero and you get the same background. A diagonal gradient from purple to blue, sometimes with a floating geometric shape in the top right. Left-aligned heading, right-side mockup image. Sans-serif body text. A rounded white-text CTA that gradients again.&lt;/p&gt;

&lt;p&gt;The lineage is traceable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;iOS/macOS popularized the aesthetic (Apple-style surface treatment).&lt;/li&gt;
&lt;li&gt;Dribbble reinforced it (gradient-heavy posts upvote well).&lt;/li&gt;
&lt;li&gt;CSS frameworks shipped it as a default (purple→blue linear-gradient works on every browser).&lt;/li&gt;
&lt;li&gt;AI training data inhaled all three.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A gradient is fine on its own. The trouble is that this particular gradient has become the default placeholder for "we haven't decided what our brand is." Many generators, same prompt, same color. If your SaaS launch page gradient-matches your competitor's launch page, your positioning work has leaked into the stylesheet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escape path:&lt;/strong&gt; Pick a background that is &lt;em&gt;not&lt;/em&gt; a gradient and &lt;em&gt;not&lt;/em&gt; pure white. Textured off-whites, warm earth tones, deep single-color navy. Anything that one generator cannot produce for free is worth more than one that twelve of them produce for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: Inter Font Dependency Syndrome
&lt;/h2&gt;

&lt;p&gt;Open the devtools on a v0-generated page. Open them on a Lovable-generated page. Open them on your competitor's new homepage. All three load Inter.&lt;/p&gt;

&lt;p&gt;Inter is a well-made typeface. Rasmus Andersson released it in 2017 and has shepherded it since, Tailwind UI templates leaned on it heavily through 2020, Next.js tutorials settled on it around 2021, Figma switched its default UI font from Roboto to Inter in March 2022. By 2023 it was in nearly every piece of UI training data on the open web. By 2026 it had stopped registering as a choice at all.&lt;/p&gt;

&lt;p&gt;That ubiquity has turned into a signal of its own. Shipping an Inter-only product in 2026 is a tell: "we did not spend one hour on type."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escape path:&lt;/strong&gt; Pair Inter with something that announces intent. Serif + mono for a developer tool. Display + grotesque for consumer. Even if you keep Inter as the body font, letting the headings carry a different voice turns a generated layout into a designed one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: Uniform Rounded Card Syndrome
&lt;/h2&gt;

&lt;p&gt;Three columns. Four columns. All cards the same height. All cards &lt;code&gt;border-radius: 8px&lt;/code&gt; or &lt;code&gt;rounded-lg&lt;/code&gt;. All cards the same &lt;code&gt;box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12)&lt;/code&gt;. All cards featuring icon + title + 2-line description.&lt;/p&gt;

&lt;p&gt;The convergence has a provable origin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- The exact Tailwind pattern every generator reaches for --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"rounded-lg shadow-md p-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Icon&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"h-10 w-10 text-indigo-500"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h3&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mt-4 font-semibold"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"mt-2 text-sm text-gray-600"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cards themselves are fine. A row of six identical ones, though, tells the reader that nothing on the page matters more than anything else. Real design communicates by making one card bigger, or weirder, or placed on its own. Generated design cannot. It has no model of which card the user is supposed to care about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escape path:&lt;/strong&gt; Break the grid. One hero card at 2× size. One row of three, then one row of two, then one standalone. The visual hierarchy is what the generator doesn't know, which means it is what you still have to add.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Slop Score that catches all three
&lt;/h2&gt;

&lt;p&gt;If you want a number, the source for this piece defines a weighted score across the five AI-UI tells:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Slop Score = (Gradient_penalty × 0.30)
           + (Inter_penalty × 0.25)
           + (Grid_uniformity × 0.20)
           + (Color_convergence × 0.15)
           + (Element_predictability × 0.10)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Score band interpretation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–30&lt;/td&gt;
&lt;td&gt;Distinctive. Escape successful&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31–60&lt;/td&gt;
&lt;td&gt;Mostly generic, some character&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;61–80&lt;/td&gt;
&lt;td&gt;Typical AI slop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;81–100&lt;/td&gt;
&lt;td&gt;Pure template&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run it against your current landing page before you run it against a competitor's. The number tends to be higher than most teams expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays recognizable in 2026
&lt;/h2&gt;

&lt;p&gt;Expect the three patterns to get more common over the next couple of years. Newer models are increasingly trained on pages that earlier generators produced, so the defaults feed back into themselves.&lt;/p&gt;

&lt;p&gt;The practical implication for engineering teams: every AI-generated UI deserves a &lt;em&gt;second pass&lt;/em&gt; that specifically removes the three signatures above. If you ship the first draft, you are shipping a photograph of the training corpus. If you spend fifteen minutes replacing the gradient, pairing the font, and breaking the card grid, you ship something that reads as yours.&lt;/p&gt;




&lt;p&gt;The three patterns above are drawn from Chapter 7 of &lt;em&gt;AI Slop Escape Guide&lt;/em&gt;. The full Zenn edition (Japanese) covers 17 chapters including before/after experiments, the full Slop Score definition, and other UI signatures beyond the three above. Canonical link to the source chapter is in the frontmatter.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>AGENTS.md Review Policy: 4 Sections CodeRabbit, Copilot, and Claude Code All Read</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 05 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/agentsmd-review-policy-4-sections-coderabbit-copilot-and-claude-code-all-read-19pj</link>
      <guid>https://dev.to/kenimo49/agentsmd-review-policy-4-sections-coderabbit-copilot-and-claude-code-all-read-19pj</guid>
      <description>&lt;p&gt;Three AI code reviewers, one file. That shift happened faster than most teams noticed.&lt;/p&gt;

&lt;p&gt;AGENTS.md was formalized in August 2025 as a shared config format co-promoted by OpenAI, Google, Sourcegraph, Cursor, Factory, and others. By September 2026, Claude Code v2.1.277 shipped native AGENTS.md support — if no &lt;code&gt;CLAUDE.md&lt;/code&gt; is present, Claude Code falls back to AGENTS.md. GitHub Copilot CLI reads it. Cursor reads it. The spec at &lt;a href="https://agents.md" rel="noopener noreferrer"&gt;agents.md&lt;/a&gt; is now the closest thing to a universal standard for AI coding agents.&lt;/p&gt;

&lt;p&gt;If one file configures three review tools, the question becomes: what should actually be in it for code review?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "review" belongs in AGENTS.md, not in a separate &lt;code&gt;.review.md&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The temptation is to split. One file for "how the agent writes code" (AGENTS.md), another for "how the agent reviews code." Resist it. Two files drift: the reviewer ends up approving patterns the writer config told the agent never to produce, and nobody notices until a bug ships.&lt;/p&gt;

&lt;p&gt;The agent that writes the code is the agent that reviews the next PR. CodeRabbit reviews what Codex just generated. Claude Code reviews what Copilot just committed. The &lt;em&gt;same&lt;/em&gt; set of conventions needs to be enforced on both sides of the slash. Splitting them into two files means they drift, and when they drift, your reviewer approves what your writer is told to avoid.&lt;/p&gt;

&lt;p&gt;So AGENTS.md needs a Review Policy block alongside the code-writing block. Four sections are enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 1: Review Policy — the Conventional Comments labels
&lt;/h2&gt;

&lt;p&gt;The first section defines the comment vocabulary. Without it, every reviewer invents their own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Code Quality&lt;/span&gt;

&lt;span class="gu"&gt;### Review Policy&lt;/span&gt;

Code review uses Conventional Comments labels:
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`issue:`&lt;/span&gt; must-fix (Approve blocked until resolved)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`suggestion:`&lt;/span&gt; improvement (optional, reviewee's call)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`nitpick:`&lt;/span&gt; small polish (optional, address if time)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`question:`&lt;/span&gt; ask, not an attack
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`praise:`&lt;/span&gt; call out good code (don't skip this)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why five labels and not three: the gradient between &lt;code&gt;issue&lt;/code&gt; and &lt;code&gt;nitpick&lt;/code&gt; is where review threads get stuck. If every comment is either "block this merge" or "do whatever you want," your senior reviewers end up relitigating style on every PR. The middle three labels (&lt;code&gt;suggestion&lt;/code&gt;, &lt;code&gt;nitpick&lt;/code&gt;, &lt;code&gt;question&lt;/code&gt;) absorb the arguments that are not worth blocking a merge over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What each tool does with this:&lt;/strong&gt; CodeRabbit will parrot the label scheme if you reference it from &lt;code&gt;.coderabbit.yaml&lt;/code&gt;. Copilot CLI will adopt the vocabulary if the review prompt references AGENTS.md. Claude Code picks it up automatically once AGENTS.md is in the repo root.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 2: Approval Criteria — the four conditions
&lt;/h2&gt;

&lt;p&gt;The second section says what "ship it" means.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Approval Criteria&lt;/span&gt;

Approve when all four hold. Don't chase perfection:
&lt;span class="p"&gt;1.&lt;/span&gt; Functional requirements are satisfied
&lt;span class="p"&gt;2.&lt;/span&gt; Tests exist (happy path + failure modes)
&lt;span class="p"&gt;3.&lt;/span&gt; No type errors
&lt;span class="p"&gt;4.&lt;/span&gt; Design aligns with the team's direction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason to write this down is that AI reviewers over-flag. CodeRabbit will raise a style issue on a two-line fix. Codex will ask for more test coverage on a hotfix. Without an Approval Criteria section, the reviewer has no stopping condition. Every PR gets more comments until someone manually overrides them.&lt;/p&gt;

&lt;p&gt;Four conditions are enough. The fourth one (team direction) is deliberately subjective. That is what Section 3 is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 3: Code Direction — the patterns with reference paths
&lt;/h2&gt;

&lt;p&gt;This is the section that makes AGENTS.md load-bearing instead of decorative.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Code Direction&lt;/span&gt;

&lt;span class="gs"&gt;**Patterns to increase:**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Repository Pattern for data access
  → See &lt;span class="sb"&gt;`src/repositories/user-repository.ts`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Result-type error handling
  → See &lt;span class="sb"&gt;`src/types/result.ts`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Pure functions for business logic (no side effects)

&lt;span class="gs"&gt;**Patterns to decrease:**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Business logic in controller layer
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`any`&lt;/span&gt; type (use &lt;span class="sb"&gt;`unknown`&lt;/span&gt; instead)
&lt;span class="p"&gt;-&lt;/span&gt; Direct DB queries (go through the ORM)
&lt;span class="p"&gt;-&lt;/span&gt; Files over 1000 lines (split responsibilities)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reference-path convention is the whole point. Abstract rules like "use Repository Pattern" don't survive contact with an AI agent. Ask three agents to "use Repository Pattern" with no reference path and you can get three different ideas of what a repository is. A path to an actual implementation (&lt;code&gt;src/repositories/user-repository.ts&lt;/code&gt;) collapses the interpretation to one.&lt;/p&gt;

&lt;p&gt;Compare:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule style&lt;/th&gt;
&lt;th&gt;What the agent does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Use Repository Pattern&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Generates something it calls a repository. Could be anything.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Use Repository Pattern → see src/repositories/user-repository.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads the file. Copies the structure.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every bullet in Code Direction should have a &lt;code&gt;→ see &amp;lt;path&amp;gt;&lt;/code&gt; if a reference implementation exists. If no reference exists yet, the first time someone implements the pattern, their file becomes the reference. That is how the section ratchets up without you writing a style guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Section 4: PR Guidelines — the mechanical rules
&lt;/h2&gt;

&lt;p&gt;The fourth section is the smallest and the most skipped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### PR Guidelines&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Target 300 lines or fewer per PR
&lt;span class="p"&gt;-&lt;/span&gt; Separate refactors from feature changes (different PRs)
&lt;span class="p"&gt;-&lt;/span&gt; PR description: what changed, and why
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 300-line target is where review quality collapses in practice. Above it, the reviewer scans. Below it, the reviewer reads. The "refactor separate from feature" rule is the one that pays for itself fastest: when a bug ships, you can bisect by PR without having to untangle a renamed function from a changed behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring AGENTS.md into CodeRabbit (&lt;code&gt;.coderabbit.yaml&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;AGENTS.md on its own gets read by Claude Code and Copilot CLI. CodeRabbit needs one more step. Point it at the Code Direction section explicitly.&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="c1"&gt;# .coderabbit.yaml&lt;/span&gt;
&lt;span class="na"&gt;reviews&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;path_instructions&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**"&lt;/span&gt;
      &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;Reference the "Code Direction" section in AGENTS.md.&lt;/span&gt;
        &lt;span class="s"&gt;Code that drifts from "patterns to increase": comment with `suggestion:`.&lt;/span&gt;
        &lt;span class="s"&gt;Code that matches "patterns to decrease": comment with `issue:`.&lt;/span&gt;

        &lt;span class="s"&gt;Always use Conventional Comments labels.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this in place, CodeRabbit comments arrive pre-labeled, matched against your real patterns, and quoting the AGENTS.md rules by reference. The maintenance cost of CodeRabbit drops to editing one file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feedback loop that AGENTS.md unlocks
&lt;/h2&gt;

&lt;p&gt;The reason to put review policy in AGENTS.md and not in a separate review-config file is that it closes the loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A review surfaces a new pattern (say, you decide to use &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt; everywhere).&lt;/li&gt;
&lt;li&gt;The pattern gets added to Code Direction with a reference path.&lt;/li&gt;
&lt;li&gt;The next PR Claude Code or Codex generates follows the new pattern.&lt;/li&gt;
&lt;li&gt;CodeRabbit flags any drift from the pattern on later PRs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a four-step feedback loop operating across three AI tools. No single-tool config file can do that. The moment the config fragments, the loop breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  When one AGENTS.md isn't enough
&lt;/h2&gt;

&lt;p&gt;A monorepo with a frontend subfolder and a backend subfolder will outgrow a single root AGENTS.md within weeks. The spec supports nesting. An AGENTS.md inside &lt;code&gt;packages/frontend/&lt;/code&gt; overrides the root for that subtree. Claude Code and Codex walk the tree down from the root; Copilot CLI only picks up AGENTS.md on the path from the current working directory up to the git root (nested-subtree discovery is tracked in &lt;a href="https://github.com/github/copilot-cli/issues/3051" rel="noopener noreferrer"&gt;copilot-cli#3051&lt;/a&gt;), so run it from the subfolder you want to scope.&lt;/p&gt;

&lt;p&gt;Scope the Review Policy section to the repository root. Scope the Code Direction section to each sub-folder. Frontend and backend will have different "patterns to increase" lists, and that is exactly what the subfolder files are for.&lt;/p&gt;




&lt;p&gt;The 4-section layout above is drawn from Chapter 6 of the Zenn book &lt;em&gt;harness-code-review&lt;/em&gt;. The full book covers the three-layer review harness: pre-commit hooks, CodeRabbit tuning, GitHub Actions review pipelines, and PR metrics that make a review culture quantifiable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/harness-code-review?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=agents-md-review-4-sections" rel="noopener noreferrer"&gt;Systematizing AI Code Review — the full harness&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Conway, Brooks, Parnas: 6 Architecture Quotes Still Deciding Code-Review Fights in 2026</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 04 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/conway-brooks-parnas-6-architecture-quotes-still-deciding-code-review-fights-in-2026-2m6g</link>
      <guid>https://dev.to/kenimo49/conway-brooks-parnas-6-architecture-quotes-still-deciding-code-review-fights-in-2026-2m6g</guid>
      <description>&lt;p&gt;Three names keep showing up in modern code reviews, and none of them wrote their best lines about code review. Conway wrote about committees. Brooks wrote about schedules. Parnas wrote about Fortran modules in 1972. Half a century later, the pull requests you merged this morning are still arguing with them.&lt;/p&gt;

&lt;p&gt;The six quotes below are the ones that keep landing in review threads when a merge stalls on the same old fight. Each one has an original context from decades ago. Each one points at a 2026 problem that didn't exist when it was written.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Conway, 1968: the quote your multi-agent system is secretly obeying
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Organizations which design systems [...] are constrained to produce designs which are copies of the communication structures of these organizations."&lt;br&gt;
— Melvin Conway, &lt;em&gt;Datamation&lt;/em&gt; 14(4):28-31, April 1968&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Conway was writing about committees inside aerospace contractors. The paper was a complaint, not a theory. Someone's design-by-committee had produced a compiler whose stage boundaries matched the subcommittee boundaries, and the result was ugly.&lt;/p&gt;

&lt;p&gt;Fifty-eight years later, the quote has found a new target: multi-agent architectures. In 2026, teams routinely spin up five, ten, twenty sub-agents to handle a workflow. The silent default is that each team ships its own sub-agent for the piece it already owns. The result is a copy of the org chart with LLM calls in it — hub bottlenecks, lossy handoffs, no shared standards. Anthropic quietly reversed course in late 2026 and started telling teams that one capable agent beats a crowd of narrow ones. That is not a modeling argument. That is Conway speaking from 1968.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-review application:&lt;/strong&gt; When a PR adds a new service, agent, or module, ask whether its boundary matches a problem shape or a team shape. If the only reason it exists is "that is the team that was free," the merge is reinforcing your org chart, not your architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Parnas, 1972: the quote that still kills export statements
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"The connections between modules are the assumptions which the modules make about each other."&lt;br&gt;
— David L. Parnas, &lt;em&gt;Information Distribution Aspects of Design Methodology&lt;/em&gt;, IFIP Congress 1971, Proceedings vol.1, pp.339-344&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Parnas was arguing with Fortran programmers. His famous paper from the same stretch — &lt;em&gt;On the Criteria To Be Used in Decomposing Systems into Modules&lt;/em&gt;, CACM 15(12), December 1972 — makes the sister point: don't carve a system by processing step. Carve it by what each module can &lt;strong&gt;hide&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The sentence above is the one you cite when you reject a PR that leaked an internal data type into a public interface. Every &lt;code&gt;export type Internal...&lt;/code&gt; that drifts into a public barrel file is a Parnas violation. In TypeScript 5.x, this is where structural typing bites hardest: an "accidentally public" shape becomes load-bearing before anyone notices, and the API can no longer evolve without a breaking change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-review application:&lt;/strong&gt; When reviewing a new public surface, list what the caller must assume about the implementation to use it. If the list is longer than three items, the module's boundary is wrong, not its implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Brooks, 1975: the quote your "we'll just add another agent" moment deserves
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Adding manpower to a late software project makes it later."&lt;br&gt;
— Fred Brooks, &lt;em&gt;The Mythical Man-Month&lt;/em&gt;, Addison-Wesley 1975, p.25&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Brooks was writing about OS/360. Half a floor of IBM programmers, one slipping deadline, more programmers added, more communication overhead, more slip. He gave the pattern a name: &lt;em&gt;Brooks's Law&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In 2026, the same shape appears without any humans involved. "The agent is too slow — let's split it into five sub-agents." "The pipeline is too long — let's add a planner on top." Every added agent is a communication edge, a context handoff, a chance for information to decay. Brooks's n²-ish communication cost shows up as token usage: the sub-agent fleet that promised speedup burned 2× the tokens of a single agent and shipped the same answer later. I have approved that PR. More than once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-review application:&lt;/strong&gt; Any PR that adds a new coordinator, planner, router, or sub-agent needs a specific sentence of justification. "Without this layer, the request fails because X." If the sentence is "it would be cleaner," reject it. You are hiring Brooks's extra programmers.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Fowler, 1999: the quote your lint rule can't enforce
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Any fool can write code that a computer can understand. Good programmers write code that humans can understand."&lt;br&gt;
— Martin Fowler, &lt;em&gt;Refactoring&lt;/em&gt;, Addison-Wesley 1999, p.15&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fowler was writing a book about renaming variables for a living. The sentence lands because it compresses the whole premise of refactoring into one line. Compile-passing is the floor, not the ceiling.&lt;/p&gt;

&lt;p&gt;The 2026 twist is that the "computer" now writes most of the code, and the "human" is both the next engineer &lt;strong&gt;and&lt;/strong&gt; the next AI agent reading &lt;code&gt;AGENTS.md&lt;/code&gt;. When Claude Code or Codex re-enters a codebase six weeks later, it reads the names, the comments, the file layout. Code that was legal but unloved — a function called &lt;code&gt;handleData&lt;/code&gt;, a parameter called &lt;code&gt;opts&lt;/code&gt;, a 300-line file called &lt;code&gt;utils.ts&lt;/code&gt; — now penalizes you twice. The human maintainer and the AI pair-programmer both get lost in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-review application:&lt;/strong&gt; Rename and relocate before you ship. In 2026, your reviewer is not just the human on the PR. It is the agent that will inherit this file at 3 a.m. in six weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Beck, 1999: the quote that keeps "we'll optimize later" honest
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Make it work, make it right, make it fast."&lt;br&gt;
— Kent Beck, Extreme Programming principles&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Beck's three-step ordering has been quoted inside out ever since XP shipped. The point was not that performance comes last. The point was that each step needs to be &lt;strong&gt;finished&lt;/strong&gt; before the next starts. "Work" means a passing test. "Right" means code the next engineer can live inside. "Fast" means a measured bottleneck, not a hunch.&lt;/p&gt;

&lt;p&gt;In 2026, the step most teams skip is still "right." AI pair-programmers happily jump from "work" to "fast" — they will generate a benchmark harness, propose a parallelized rewrite, and never once ask whether the function should exist. I have hit merge on exactly that PR, which is how I know. "Make it right" is the step where a human reviewer still has to sit down and ask: does this belong in this module? Does this abstraction pay rent? Does the function name match what it actually does?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-review application:&lt;/strong&gt; When a PR comes in with a passing test and a 10% speedup, don't merge it. The missing step is "right." Ask the author to show the version that is clean, small, and obvious. Then compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Hickey, 2011: the lesser-known quote that keeps winning
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;"Simple is actually an objective notion. [...] Easy is relative. [...] easy for whom, or hard for whom? It's a relative term."&lt;br&gt;
— Rich Hickey, &lt;em&gt;Simple Made Easy&lt;/em&gt;, Strange Loop 2011&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hickey, the creator of Clojure, delivered this talk at Strange Loop in 2011. The core distinction: &lt;em&gt;simple&lt;/em&gt; means one role, one task, one axis. It braids no other concerns. &lt;em&gt;Easy&lt;/em&gt; means familiar, near-at-hand, matching what you already know.&lt;/p&gt;

&lt;p&gt;The talk has had a second life since 2024. Every "we're adopting X because it is easy" decision in the AI stack has run into Hickey's framing within a year. LangChain was easy, until it braided prompts, tools, state, and routing into one object. "Easy" let you ship the demo. "Simple" would have let you refactor it without a rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-review application:&lt;/strong&gt; When a PR picks a library "because everyone uses it," ask what the library &lt;em&gt;braids together&lt;/em&gt;. If pulling it out requires touching four unrelated concerns, you have imported ease, not simplicity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these six, in this order
&lt;/h2&gt;

&lt;p&gt;These aren't the six most quoted. "Premature optimization is the root of all evil" (Knuth, 1974) is more quoted than any of them. The six above are chosen for a different reason: each one points at a 2026 argument that is still live in your pull-request queue this week.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conway&lt;/strong&gt; fires when a sub-agent split copies the team split.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parnas&lt;/strong&gt; fires when a public interface starts carrying implementation leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brooks&lt;/strong&gt; fires when the fix for a slow agent is a bigger agent fleet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fowler&lt;/strong&gt; fires when the next reader is now an AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beck&lt;/strong&gt; fires when "right" gets skipped between "work" and "fast."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hickey&lt;/strong&gt; fires every time a stack picks a library for familiarity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a code review stalls, drop the matching quote into the thread. The author either rebuts it or revises the PR. Either outcome is useful.&lt;/p&gt;




&lt;p&gt;The six above are a thin slice of a larger shelf. If you want ~100 engineering quotes with the original context next to the modern application — not a generic business quote book, but one that puts lesser-known voices like Hickey next to Fowler — the full collection is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/books/engineer-it-quotes?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=conway-brooks-parnas-6-quotes" rel="noopener noreferrer"&gt;Why Do Some Words Stay With You Forever? 100 Engineering Quotes Decoded&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>5 PR Metrics on 303 Merged PRs from pytest and pydantic: 2 Point at Review Bottlenecks</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/5-pr-metrics-on-303-merged-prs-from-pytest-and-pydantic-2-point-at-review-bottlenecks-2o17</link>
      <guid>https://dev.to/kenimo49/5-pr-metrics-on-303-merged-prs-from-pytest-and-pydantic-2-point-at-review-bottlenecks-2o17</guid>
      <description>&lt;p&gt;Engineering-productivity dashboards tend to show the same five pull-request metrics: Time to First Review, Time to Merge, Comments per PR, Review Cycles, and AI Resolution Rate. Each gets a card and a trend line.&lt;/p&gt;

&lt;p&gt;I wanted to know which of them can answer one narrow question: "is review the slow part of shipping a change?" My own repositories are no help here. I work alone, and my busiest repo merged four PRs in the last 90 days. So I pointed a script at two public projects with real review traffic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pytest-dev/pytest&lt;/strong&gt;: 107 merged PRs from humans (166 merged, 59 from bots), 46 authors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pydantic/pydantic&lt;/strong&gt;: 196 merged PRs from humans (221 merged, 25 from bots), 40 authors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The window is PRs merged between 2026-07-21 and 2026-10-19 (UTC). All numbers below come from the GitHub REST API, and you can rerun the script to check them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# review_metrics.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;median&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Github&lt;/span&gt;

&lt;span class="n"&gt;REPO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pytest-dev/pytest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;END&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Github&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_repo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_pulls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;closed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;desc&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;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;  &lt;span class="c1"&gt;# sorted by updated: nothing older can have merged in the window
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[bot]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;

    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt;
    &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_reviews&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;login&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_at&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_at&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;approvals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;submitted_at&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APPROVED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;

    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;created_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merged_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_review_h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&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;first&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge_h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_approval_to_merge_h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&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;approvals&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review_comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;review_comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cycles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHANGES_REQUESTED&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;APPROVED&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;changed_lines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;additions&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deletions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&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;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  median=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  mean=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;REPO&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  merged &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;..&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;Y&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  human PRs: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRs with no review from anyone but the author: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;first_review_h&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_review_h&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;merge_h&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;last_approval_to_merge_h&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;review_comments&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;cycles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three definitions matter. A "review" is any submitted GitHub review (approve, request changes, or comment) from someone other than the PR author. &lt;code&gt;review_comments&lt;/code&gt; counts inline code comments only, not the conversation tab. A "cycle" is one approve or request-changes event.&lt;/p&gt;

&lt;p&gt;Raw output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pytest-dev/pytest  merged 2026-07-21..2026-10-19  human PRs: 107
PRs with no review from anyone but the author: 0
first_review_h             n=107  median=    9.2  mean=  284.6
merge_h                    n=107  median=  143.4  mean=  806.0
last_approval_to_merge_h   n=107  median=    1.8  mean=  128.3
review_comments            n=107  median=    0.0  mean=    3.3
cycles                     n=107  median=    1.0  mean=    1.5

pydantic/pydantic  merged 2026-07-21..2026-10-19  human PRs: 196
PRs with no review from anyone but the author: 100
first_review_h             n= 96  median=    0.2  mean=   38.2
merge_h                    n=196  median=    1.6  mean=   63.4
last_approval_to_merge_h   n= 41  median=    0.3  mean=   28.7
review_comments            n=196  median=    0.0  mean=    0.8
cycles                     n=196  median=    0.0  mean=    0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in that output are worth more than any single metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the average describes five PRs
&lt;/h2&gt;

&lt;p&gt;pytest's mean Time to First Review is 284.6 hours. The median is 9.2 hours. A dashboard that shows the mean says "nearly 12 days"; nearly two thirds of the PRs got one within 24 hours.&lt;/p&gt;

&lt;p&gt;The gap comes from a handful of old PRs. Five of the 107 were opened between January and early July 2026 and only merged inside the window. Their first-review waits (5,568h, 5,415h, 4,261h, 3,305h, 1,650h) add up to 66% of all first-review hours in the sample. Drop those five and the mean falls to 100.5 hours.&lt;/p&gt;

&lt;p&gt;The spread is wide even without them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;pytest, Time to First Review&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;25th percentile&lt;/td&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;median&lt;/td&gt;
&lt;td&gt;9.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75th percentile&lt;/td&gt;
&lt;td&gt;95.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90th percentile&lt;/td&gt;
&lt;td&gt;507.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;43 of 107 PRs (40%) got a first review within 4 hours, 69 (64%) within 24 hours, and 20 waited more than a week. "Share of PRs reviewed within N hours" plus the 90th percentile says far more than an average.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the metric quietly skips half the PRs
&lt;/h2&gt;

&lt;p&gt;In pydantic, 100 of 196 human PRs were merged without any GitHub review from someone other than the author. 75 of those 100 came from a single maintainer. Unreviewed PRs merged in a median of 0.3 hours, reviewed ones in 7.5 hours.&lt;/p&gt;

&lt;p&gt;Time to First Review cannot be computed for a PR that never got a review, so the 0.2-hour median above describes only 96 of the 196 PRs. Whether merging without a review is fine depends on the project's rules, and I am not judging pydantic's. The dashboard problem is that the number looks complete when it covers 49% of merges. Put the coverage next to the metric: "median 0.2h, 96 of 196 PRs reviewed".&lt;/p&gt;

&lt;h2&gt;
  
  
  The two metrics that point at review
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time to First Review&lt;/strong&gt;, reported as a median and percentiles with coverage, measures the stretch where a PR waits and no reviewer has acted. That wait is a review-process delay by definition.&lt;/p&gt;

&lt;p&gt;Published research gives a reference point. Google's 2018 case study of its own code review, based on logs of about nine million reviewed changes, reports a median wait for initial feedback of under an hour for small changes and about 5 hours for very large ones, with an overall median under 4 hours for the whole review process (&lt;a href="https://sback.it/publications/icse2018seip.pdf" rel="noopener noreferrer"&gt;Sadowski et al., ICSE-SEIP 2018&lt;/a&gt;). That is a large company with dedicated tooling, so treat it as a reference point rather than a target for a volunteer project.&lt;/p&gt;

&lt;p&gt;The lever with the strongest evidence is explicit assignment. Meta suspected that diffs assigned only to a team were slowed by the bystander effect ("someone else will probably review this") and tested it. In an A/B test on 12.5k authors in spring 2023, it randomly assigned one of the top three recommended reviewers on team-only diffs. TimeInReview fell 11.6%, with no significant change in the time reviewers spent on each diff (&lt;a href="https://arxiv.org/abs/2312.17169" rel="noopener noreferrer"&gt;Improving Code Reviewer Recommendation, ACM TOSEM 2026, arXiv:2312.17169&lt;/a&gt;). You do not need Meta's recommender to try the idea. GitHub can &lt;a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" rel="noopener noreferrer"&gt;request code owners automatically&lt;/a&gt;, and team review requests can be &lt;a href="https://docs.github.com/en/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team" rel="noopener noreferrer"&gt;routed to individual members by round robin or load balance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review Cycles&lt;/strong&gt; measures whether review converges. In pytest, 68 of 107 PRs merged after one approve-or-request-changes event, 26 after two, and 13 after three to five. Cycles barely tracked PR size (Spearman 0.19 against changed lines), so a high count is less about big diffs and more about how a review gets run, for example comments arriving in several rounds. That makes it a review-process signal you can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three that answer other questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time to Merge&lt;/strong&gt; mixes three intervals: waiting for a first review, the back-and-forth until the last approval, and the wait from last approval to merge. I split every pytest PR into those three segments and looked at which one was longest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Longest segment of Time to Merge (pytest)&lt;/th&gt;
&lt;th&gt;PRs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Waiting for first review&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval to merge&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First review to last approval&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Summed over all PRs, the split was 35% waiting, 49% back-and-forth, 16% after approval. One number covers three different problems, and the approval-to-merge part (CI, merge queues, release timing) is outside review entirely. Time to Merge is still a fine throughput metric. It just cannot tell you review is the bottleneck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comments per PR&lt;/strong&gt; mostly tracks size. In pytest, inline comment count correlated with changed lines at Spearman 0.53, and 67 of 107 PRs (63%) had zero inline comments. In pydantic, 135 of 196 had none. A month-over-month rise in the average usually reflects a shift in PR mix. What it can flag is a sustained zero on large PRs, which is worth a look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI Resolution Rate&lt;/strong&gt; cannot be computed from the GitHub API alone, because GitHub does not record whether a bot comment was "resolved" in the sense the metric needs. You need data from the AI review tool itself, so I did not measure it here. One structural caution applies anyway: the rate depends on what the bot chooses to flag, so changing the tool or its rules moves the number without any change in how your team responds. Compare it only within one tool and one configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would put on the dashboard
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Time to First Review: median, 90th percentile, share reviewed within 4h and 24h, and how many merged PRs had a review at all&lt;/li&gt;
&lt;li&gt;Review Cycles: distribution (1 / 2 / 3+) alongside the mean&lt;/li&gt;
&lt;li&gt;Time to Merge, Comments per PR, AI Resolution Rate: on a separate throughput or tooling page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rerun the script on your own repo before you trust any of these. The two projects here behaved so differently (pytest reviews every PR, pydantic merged half without one) that a single benchmark would have misled both.&lt;/p&gt;

&lt;p&gt;The longer version, with the three-layer review model (hooks, AI review, human review), the CodeRabbit configuration, and a GitHub Actions workflow that writes a weekly metrics report, is in my book &lt;a href="https://kenimoto.dev/books/harness-code-review/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=5-pr-metrics-review-signal" rel="noopener noreferrer"&gt;Systematizing AI Code Review&lt;/a&gt;. The English edition is on Kindle.&lt;/p&gt;




&lt;p&gt;Sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/pytest-dev/pytest" rel="noopener noreferrer"&gt;pytest-dev/pytest&lt;/a&gt; and &lt;a href="https://github.com/pydantic/pydantic" rel="noopener noreferrer"&gt;pydantic/pydantic&lt;/a&gt;, GitHub REST API, PRs merged 2026-07-21 to 2026-10-19&lt;/li&gt;
&lt;li&gt;Sadowski, Söderberg, Church, Sipko, Bacchelli. &lt;a href="https://sback.it/publications/icse2018seip.pdf" rel="noopener noreferrer"&gt;Modern Code Review: A Case Study at Google&lt;/a&gt;. ICSE-SEIP 2018&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2312.17169" rel="noopener noreferrer"&gt;Improving Code Reviewer Recommendation: Accuracy, Latency, Workload, and Bystanders&lt;/a&gt;. ACM TOSEM 35(1), 2026&lt;/li&gt;
&lt;li&gt;GitHub Docs: &lt;a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" rel="noopener noreferrer"&gt;About code owners&lt;/a&gt;, &lt;a href="https://docs.github.com/en/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team" rel="noopener noreferrer"&gt;Managing code review settings for your team&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>productivity</category>
      <category>devops</category>
      <category>github</category>
      <category>opensource</category>
    </item>
    <item>
      <title>RTX 4070 + logprobs: 96 of 100 Claude Code Requests Needed the Cloud. 97 of 200 Steps Could Go Local</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/rtx-4070-logprobs-96-of-100-claude-code-requests-needed-the-cloud-97-of-200-steps-could-go-local-1nn1</link>
      <guid>https://dev.to/kenimo49/rtx-4070-logprobs-96-of-100-claude-code-requests-needed-the-cloud-97-of-200-steps-could-go-local-1nn1</guid>
      <description>&lt;p&gt;Routing whole requests to a local LLM did not work for my Claude Code workload. I took 100 jobs from my work log (March to September 2026), rewrote each one as the instruction I would have typed, and labelled where it could run. 96 needed a frontier model. The other 4 had to stay local because they contained personal or financial details, not because a local model could do them. Zero were simply "a local model is good enough."&lt;/p&gt;

&lt;p&gt;Then the unit went down to single steps inside agent sessions. Out of 200 tool-call steps pulled from my Claude Code logs, 97 were labelled "a 4B to 9B local model is enough if you hand it the material." That is 48.5%.&lt;/p&gt;

&lt;p&gt;The step-level router that came out of this is on GitHub under MIT: &lt;a href="https://github.com/kenimo49/local-step-router" rel="noopener noreferrer"&gt;&lt;code&gt;kenimo49/local-step-router&lt;/code&gt;&lt;/a&gt;. This post covers the numbers behind it, what the router code actually looks like, and where a logprobs judge on an RTX 4070 helped and where it did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why request-level routing failed
&lt;/h2&gt;

&lt;p&gt;A request like "fix the flaky test" is a label, not a payload. Nothing in the text tells a local model what to do until something reads the repo. In the 100 reconstructed requests, the task mix was mostly agent work (74 of 100), and agent work is exactly what you hand Claude Code in the first place.&lt;/p&gt;

&lt;p&gt;Raw instructions told the same story. Of 150 instructions sampled from interactive sessions, 138 only made sense with the earlier conversation ("send it", "do the daily log"), and only 5 were labelled local-capable. Of 100 fixed prompts that my cron pipeline sends to &lt;code&gt;claude -p&lt;/code&gt;, also 5.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Local is enough&lt;/th&gt;
&lt;th&gt;Must stay local (sensitive)&lt;/th&gt;
&lt;th&gt;Frontier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests rebuilt from the work log&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw instructions&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;143&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed pipeline prompts&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Single steps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which steps go local
&lt;/h2&gt;

&lt;p&gt;The 200 steps came from three tool types in the logs. The split is lopsided:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step type&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;Local is enough&lt;/th&gt;
&lt;th&gt;Must stay local&lt;/th&gt;
&lt;th&gt;Frontier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;WebFetch&lt;/code&gt; (extract something from a page)&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git commit&lt;/code&gt; message&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-agent delegation&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;97&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Almost all of the local share is &lt;code&gt;WebFetch&lt;/code&gt;. A fetch prompt usually says what to pull out ("the price", "the release date"), and once the page text is in hand, that is extraction. Commit messages go local only when they are one-line summaries of a diff. Sub-agent delegation is research and multi-step tool use, and it stays frontier.&lt;/p&gt;

&lt;p&gt;Two caveats matter here. The labels are judgments, not runs: "local is enough" means "given the page body or the diff." And they were assigned by my AI assistant, with my review planned only for disagreements.&lt;/p&gt;

&lt;p&gt;So I ran a sample. 20 of the 86 local-labelled &lt;code&gt;WebFetch&lt;/code&gt; steps went to qwen3.5:9b on Ollama (&lt;code&gt;think=false&lt;/code&gt;, temperature 0), with the page re-fetched and extracted by trafilatura. 11 could be compared with the original output: 8 matched, 2 were partly wrong (a merged-cell spec table read into the wrong column, a title reported as missing), and 1 was wrong (it quoted "about 1 to 3 months" as evidence and still answered yes to "does it say 2 to 3 months?"). The other 9 never reached a fair comparison: JS-rendered pages with no extractable text, pages that had disappeared, or an original fetch that had already failed. Local processing took 6 to 66 seconds per item, median about 18. Most of the loss happened before the model, in fetching and extracting the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The router: rules first
&lt;/h2&gt;

&lt;p&gt;The repo's &lt;code&gt;Router&lt;/code&gt; checks rules top to bottom, first match wins. A probability judge only runs for steps no rule matched, and it is off by default. This is the shipped &lt;code&gt;examples/routing.yaml&lt;/code&gt;, rules part:&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;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# step rules, top to bottom, first match wins&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;WebFetch&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Agent&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Task&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;commit_message&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;         &lt;span class="c1"&gt;# frontier until a probability judge is added&lt;/span&gt;

&lt;span class="na"&gt;probability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;           &lt;span class="c1"&gt;# off until measured with the same judge model (chapter 14)&lt;/span&gt;
&lt;span class="na"&gt;default_route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontier&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And from Python:&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;steprouter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;steprouter.router&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;examples/routing.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WebFetch&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;prompt&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;Extract the price table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;# Decision(route='local', by='rule', p_local=None)
&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Edit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;# Decision(route='frontier', by='default', p_local=None)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Decision.by&lt;/code&gt; tells you whether a rule, the probability judge, or the default made the call.&lt;/p&gt;

&lt;p&gt;Why rules first: on the 444 non-sensitive items across all three sets (107 labelled local-capable), I compared a single rule against asking a local model for P(local is enough):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Router&lt;/th&gt;
&lt;th&gt;Local-capable items caught (of 107)&lt;/th&gt;
&lt;th&gt;Frontier items sent local by mistake (of 337)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;qwen3.5:9b probability, threshold set for at most 5% wrong-locals (AUROC 0.809)&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma4:e4b probability, same calibration (AUROC 0.618)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One rule: &lt;code&gt;WebFetch&lt;/code&gt; goes local&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 9b judge's AUROC of 0.809 was mostly the judge recognising the step type. Within one type it could barely separate items: 0.095 on the pipeline prompts, 0.411 on raw instructions. If the step type decides, write a rule. Keep the probability for what the rules leave open.&lt;/p&gt;

&lt;p&gt;I also tried qwen3.5:4b as the routing judge. It timed out under GPU contention at 414 of 450 items, so it has no routing result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading logprobs from Ollama
&lt;/h2&gt;

&lt;p&gt;Where the local model does earn its place is the entrance gate: "does this request contain actual sensitive data?" The judge asks for a one-letter answer (Y or N), generates exactly one token, and reads the candidate letters from &lt;code&gt;top_logprobs&lt;/code&gt;. In the repo this is &lt;code&gt;steprouter/judge.py&lt;/code&gt;, using the native &lt;code&gt;/api/chat&lt;/code&gt; with &lt;code&gt;think: false&lt;/code&gt;, &lt;code&gt;num_predict: 1&lt;/code&gt; and &lt;code&gt;top_logprobs: 20&lt;/code&gt;. It returns the probabilities renormalised over the candidates, plus &lt;code&gt;mass&lt;/code&gt;: how much probability landed on the candidates at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mass&lt;/code&gt; is the field to watch, because two things make it collapse without any error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first token is not the answer.&lt;/strong&gt; A model that reasons before answering spends token 0 on its reasoning. With &lt;code&gt;think: false&lt;/code&gt;, qwen3.5 answers in the first token. gpt-oss:20b did not put a label there and gave mass 0 on my setup, so I could not use it this way. The other models I tried stayed between 0.89 (qwen3.5:0.8b) and 1.00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A candidate outside the top 20 reads as 0.&lt;/strong&gt; Ollama rejects &lt;code&gt;top_logprobs&lt;/code&gt; above 20 (&lt;a href="https://github.com/ollama/ollama/blob/main/server/routes.go" rel="noopener noreferrer"&gt;the check in &lt;code&gt;server/routes.go&lt;/code&gt;&lt;/a&gt;; &lt;a href="https://github.com/ollama/ollama/pull/18591" rel="noopener noreferrer"&gt;a PR to raise it to 100&lt;/a&gt; was closed without merging). If a candidate is not in that list, its probability is simply missing, and the others share 100%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repo's judge marks a result unusable when &lt;code&gt;mass&lt;/code&gt; is below 0.5. A first test on any new judge model: send one known question and check that mass is close to 1.&lt;/p&gt;

&lt;p&gt;One correction to something you may have read, including in an earlier version of my own notes: the OpenAI-compatible &lt;code&gt;/v1/chat/completions&lt;/code&gt; endpoint does return logprobs. The &lt;a href="https://github.com/ollama/ollama/releases/tag/v0.12.11" rel="noopener noreferrer"&gt;v0.12.11 release notes&lt;/a&gt; say both APIs support them, and in &lt;a href="https://github.com/ollama/ollama/issues/16117" rel="noopener noreferrer"&gt;issue #16117&lt;/a&gt; a maintainer showed it working before the reporter closed it as their own client bug. The compatibility page in the docs still marks logprobs unsupported. The repo uses the native endpoint, so it does not depend on which of those is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threshold is not 0.5
&lt;/h2&gt;

&lt;p&gt;qwen3.5:4b ranked the 60 English test requests almost perfectly for sensitive content (AUROC 0.999), but its P(Y) stayed low. With a fixed 0.5 line it missed all 20 sensitive requests. Drawing the line from labelled examples with 5-fold cross-validation brought that to 2 misses and 1 false alarm. On the Japanese version of the same set, the fixed line missed 9 of 20 and the calibrated one missed 1.&lt;/p&gt;

&lt;p&gt;The repo's CLI does this from your own judged examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/judge_set.py qwen3.5:4b en &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; judged.jsonl
steprouter-threshold judged.jsonl &lt;span class="nt"&gt;--margin&lt;/span&gt; 0.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints JSON with &lt;code&gt;auroc&lt;/code&gt;, the &lt;code&gt;line&lt;/code&gt; (lowest P among your positive examples times the margin), and 5-fold &lt;code&gt;cv&lt;/code&gt; counts of misses and false alarms, and it warns when AUROC is under 0.95. The data set is 60 made-up requests, so treat these as small-sample numbers, and redraw the line on your own labels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the RTX 4070 fits
&lt;/h2&gt;

&lt;p&gt;All of the judging above ran through Ollama on one RTX 4070 12 GB (version 0.20.2 for the 60-request tests). Small judges were not faster on that box: qwen3.5:0.8b through 9b all took about 0.5 to 0.7 seconds for two questions, mostly network round trip, while qwen3.5:35b-a3b took about 3.3 seconds because it does not fit in 12 GB. If you want a 35B MoE model on the same card for heavier local work, the &lt;code&gt;--cpu-moe&lt;/code&gt; flag in llama.cpp took it from 12.2 to 34.6 tok/s in &lt;a href="https://kenimoto.dev/blog/rtx-4070-cpu-moe-flag-2-8x-tokens/" rel="noopener noreferrer"&gt;a separate benchmark&lt;/a&gt;. That benchmark is about speed; the routing numbers above did not use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would copy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count your own steps before tuning anything.&lt;/strong&gt; My 48.5% is almost entirely &lt;code&gt;WebFetch&lt;/code&gt;. A workload with fewer fetches will get a smaller share.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide by step type first.&lt;/strong&gt; One &lt;code&gt;WebFetch&lt;/code&gt; rule beat both probability judges in my data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;mass&lt;/code&gt;, then draw the threshold from your own labels.&lt;/strong&gt; A fixed 0.5 missed every sensitive English request with qwen3.5:4b.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The code: &lt;a href="https://github.com/kenimo49/local-step-router" rel="noopener noreferrer"&gt;&lt;code&gt;kenimo49/local-step-router&lt;/code&gt;&lt;/a&gt;. MIT, Python 3.10+, PyYAML as the only dependency, Ollama 0.12.11 or later for the judge. The tests run without Ollama.&lt;/p&gt;

&lt;p&gt;The full write-up, with the step labelling criteria, the sensitive-data gate, and the typed return values, is the book this repo accompanies: &lt;a href="https://kenimoto.dev/books/local-llm-step-routing?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rtx-4070-logprobs-router" rel="noopener noreferrer"&gt;Local LLM or Claude Code?&lt;/a&gt; Kindle, included in Kindle Unlimited.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GitHub Stars Lied: 3 Tech-Selection Traps Senior Devs Still Walk Into</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/github-stars-lied-3-tech-selection-traps-senior-devs-still-walk-into-4f82</link>
      <guid>https://dev.to/kenimo49/github-stars-lied-3-tech-selection-traps-senior-devs-still-walk-into-4f82</guid>
      <description>&lt;p&gt;In the Stack Overflow Developer Survey 2025, 17.6% of respondents said they had done extensive work in Vue.js over the past year. On W3Techs (checked 2026-10-19), Vue.js runs on 0.6% of all websites. That is a 29x spread.&lt;/p&gt;

&lt;p&gt;Angular: 18.2% in the survey, 0.2% of websites. About 90x. React, the safe pick: 44.7% against 6.0%, about 7x.&lt;/p&gt;

&lt;p&gt;Neither number is wrong. The survey counts developers who answered a question about frameworks. W3Techs counts every website it can detect, and most of those are not hand-built single-page apps. The spread mostly says that the two denominators have nothing to do with each other.&lt;/p&gt;

&lt;p&gt;That is the useful part. Every popularity number answers a question about some population, and in a tech-selection meeting nobody asks which population. GitHub stars are the most quoted popularity number of all, and they have a population problem of their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the bandwagon effect, dressed as "community size"
&lt;/h2&gt;

&lt;p&gt;The bandwagon effect is the tendency to judge something as right because many people have adopted it. Part of that is rational. A widely used tool has more documentation, a more mature ecosystem, and a bigger hiring pool. The trouble starts when that advantage outweighs every other axis.&lt;/p&gt;

&lt;p&gt;GitHub stars, npm download counts, Stack Overflow answers. All three signal community size. None of them signal fit for your project. As of 2026-10-19 (GitHub API), &lt;code&gt;apache/echarts&lt;/code&gt; has 67.5k stars, &lt;code&gt;chartjs/Chart.js&lt;/code&gt; has 67.7k, and &lt;code&gt;d3/d3&lt;/code&gt; has 113.8k. ECharts and Chart.js look like twins on a comparison table. D3 looks like the winner. D3 is a low-level toolkit for building visualizations; the other two hand you finished chart types. The star column cannot show that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Some of the stars are not people
&lt;/h3&gt;

&lt;p&gt;A study by He et al., published at ICSE 2026, built a detector called StarScout and ran it over all GitHub event data from July 2019 to December 2024. It flagged 6.0 million suspected fake stars and 18,617 repositories with fake-star campaigns. Fake stars stayed at or below 1% of all stars in any month, but campaigns grew from 2022 and surged in 2024. Most of them promoted short-lived phishing malware repositories. The rest went mostly to AI/LLM, blockchain, tool, and tutorial repos.&lt;/p&gt;

&lt;p&gt;The paper also measured whether buying stars works. The effect on real attention lasts under two months, is about 5x smaller than the effect of real stars, and turns negative over the long term.&lt;/p&gt;

&lt;p&gt;So for a big, old project, fake stars are a rounding error. For a young repo in a hyped category, the star count can be partly bought. Either way, stars are a weak input, and the bandwagon effect makes them the loudest one.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the effect gets stronger
&lt;/h3&gt;

&lt;p&gt;The bandwagon effect strengthens under three conditions, and all three describe a normal tech-selection meeting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your own expertise in the domain is shallow (so you lean on "what everyone picks")&lt;/li&gt;
&lt;li&gt;The decision carries visible blame if it fails (so "we picked the popular one" is the safe story)&lt;/li&gt;
&lt;li&gt;You are under time pressure (so detailed comparison gets compressed into "which has more stars")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fifty thousand stars on one framework and five hundred on a competitor is the setup that triggers all three, even when the smaller one fits your use case better.&lt;/p&gt;

&lt;h3&gt;
  
  
  What catches it: a requirements matrix
&lt;/h3&gt;

&lt;p&gt;Telling the room to "be careful about stars" does nothing. Changing the table does.&lt;/p&gt;

&lt;p&gt;List the project's requirements before you look at candidates: performance targets, the team's existing skills, licensing, long-term support, learning cost. Then add community size as one more row, weighted like the others. Score every candidate on every row. Stars now contribute one row out of six, and the five rows that actually describe your project get filled in before anyone opens a GitHub tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the IKEA effect on "the thing we built ourselves"
&lt;/h2&gt;

&lt;p&gt;The IKEA effect is the tendency to overvalue things you put labor into. Norton, Mochon, and Ariely described it after experiments in which people assembled IKEA boxes, folded origami, and built Lego sets, then valued their own results above equivalent ready-made ones. Code is labor you can see, so the effect carries over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An in-house framework that two engineers wrote in a hackathon five years ago, defended with "it fits our needs exactly"&lt;/li&gt;
&lt;li&gt;A custom build config that only one person can modify, kept because "replacing it is risky"&lt;/li&gt;
&lt;li&gt;A fork of a library that has drifted three major versions, maintained because "we invested a lot of time understanding it"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not Invented Here syndrome is the acute version. The chronic version is quieter: a steady preference for the thing that has your team's hours in it.&lt;/p&gt;

&lt;p&gt;Market numbers will not prove the effect for you. npm counted 76.6 million Vitest downloads and 26.0 million Jest downloads in the week of 2026-10-10 to 10-16. Jest is still a very large installed base. Some of those projects stay because Jest fits them; some stay because of years of local config. The download count cannot tell you which, and neither can you, until you ask the question below about your own stack.&lt;/p&gt;

&lt;p&gt;The point to hold on to: "we built it" is a fact about past labor. It says nothing about quality. The question is what the next twelve months of maintenance will cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  The question that defuses it
&lt;/h3&gt;

&lt;p&gt;One line, on a sticky note by the monitor:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If this system did not exist and we were choosing today, would we pick this?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A no does not mean "migrate now". Migration costs are real. A no means the current choice may be held in place by the IKEA effect plus the next trap, and deserves an actual comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: sunk cost, also known as the Concorde fallacy
&lt;/h2&gt;

&lt;p&gt;Everyone can define the sunk cost fallacy, and almost nobody applies it. "We have already invested three months in this refactor" is the shape. The three months are gone. They do not come back if you continue, and they do not vanish if you stop. The only live comparison is the cost of continuing from here against the cost of stopping and switching from here.&lt;/p&gt;

&lt;p&gt;The nickname comes from Concorde. Britain and France signed the development treaty in 1962, and costs overran the original estimate many times over. The British government privately regarded the project as a commercial disaster that should never have been started, yet both governments kept paying. Political and legal commitments made withdrawal hard, which is a lesson in itself: sunk cost rarely acts alone. Twenty aircraft were built, fourteen entered airline service, and the fleet retired in 2003.&lt;/p&gt;

&lt;p&gt;In engineering, sunk cost shows up as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deferred refactors ("throwing this code away wastes three months of work")&lt;/li&gt;
&lt;li&gt;A struggling project that keeps going ("we are 60% of the way there")&lt;/li&gt;
&lt;li&gt;Unused features kept alive ("we went to the trouble of building it")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those sentences is a sunk-cost claim in disguise.&lt;/p&gt;

&lt;h3&gt;
  
  
  The forward-only decision frame
&lt;/h3&gt;

&lt;p&gt;The frame that works gives "already spent" zero weight. Three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set all past investment to zero. Looking only at future cost and return, which option wins?&lt;/li&gt;
&lt;li&gt;What does staying on this choice cost over the next year (maintenance hours, learning cost for new hires, effect on hiring)?&lt;/li&gt;
&lt;li&gt;What is the one-time cost of switching, and how much does it save per year afterward? Does the saving pay back the switch within a horizon that matters?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Already spent" is the loudest voice in a technical meeting. It is also the one voice that cannot change the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hype cycle chains all three
&lt;/h2&gt;

&lt;p&gt;Gartner's hype cycle describes technology maturity in five phases, and a different bias dominates each part of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Innovation Trigger to Peak of Inflated Expectations&lt;/strong&gt;: the bandwagon effect at maximum. "Do not get left behind" overrides every other signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trough of Disillusionment&lt;/strong&gt;: negativity bias takes over, and the technology gets written off below its real value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slope of Enlightenment to Plateau of Productivity&lt;/strong&gt;: calm evaluation finally becomes possible, but whatever was adopted at the peak is now defended by sunk cost ("we cannot throw it away now") and the IKEA effect ("we worked hard to make it run").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you adopted during the current AI tooling peak will meet this chain on schedule. When the plateau arrives, the choice will be defended by two biases at once, and the forward-only frame is the tool for that review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three sentences for the wall
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;GitHub stars are one row out of six in the requirements matrix. Not a tiebreaker.&lt;/li&gt;
&lt;li&gt;"If this did not exist, would we pick it today?" Ask it for every stack you maintain, once a quarter.&lt;/li&gt;
&lt;li&gt;Money already spent is zero on the future-cost line. The only live comparison is forward.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tech selection is a judgment you keep revisiting, and the three biases keep running the whole time. The sentences do not switch them off. They make the room notice when a bias is making the decision, and that is usually enough to get the actual comparison onto the whiteboard.&lt;/p&gt;

&lt;p&gt;These three traps and the hype-cycle chain are Chapter 5 of &lt;a href="https://kenimoto.dev/books/engineer-psychology-tricks?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=github-stars-lied-3-traps" rel="noopener noreferrer"&gt;&lt;em&gt;Cognitive Bias for Software Engineers&lt;/em&gt;&lt;/a&gt;. Chapter 6 applies the same lens to debugging, starting with confirmation bias and the habit of holding several hypotheses at once. It is on Kindle in English, Japanese, and Portuguese.&lt;/p&gt;




&lt;p&gt;Sources (all checked 2026-10-19):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;Stack Overflow Developer Survey 2025: Technology&lt;/a&gt;: web frameworks, all respondents (React 44.7%, Angular 18.2%, Vue.js 17.6%)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://w3techs.com/technologies/comparison/js-angularjs,js-react,js-vuejs" rel="noopener noreferrer"&gt;W3Techs: React vs. Vue.js vs. Angular&lt;/a&gt;: share of all websites (React 6.0%, Vue.js 0.6%, Angular 0.2%), W3Techs data of 19 October 2026&lt;/li&gt;
&lt;li&gt;GitHub REST API, &lt;code&gt;gh api repos/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;&lt;/code&gt;, 2026-10-19: &lt;a href="https://github.com/apache/echarts" rel="noopener noreferrer"&gt;apache/echarts&lt;/a&gt; 67,543 stars, &lt;a href="https://github.com/chartjs/Chart.js" rel="noopener noreferrer"&gt;chartjs/Chart.js&lt;/a&gt; 67,746, &lt;a href="https://github.com/d3/d3" rel="noopener noreferrer"&gt;d3/d3&lt;/a&gt; 113,767&lt;/li&gt;
&lt;li&gt;He, Yang, Burckhardt, Kapravelos, Vasilescu, Kästner, &lt;a href="https://arxiv.org/abs/2412.13459" rel="noopener noreferrer"&gt;&lt;em&gt;Six Million (Suspected) Fake Stars in GitHub: A Growing Spiral of Popularity Contests, Spams, and Malware&lt;/em&gt;&lt;/a&gt;, ICSE 2026 (doi:10.1145/3744916.3764531)&lt;/li&gt;
&lt;li&gt;npm downloads API, last week (2026-10-10 to 2026-10-16): &lt;a href="https://api.npmjs.org/downloads/point/last-week/vitest" rel="noopener noreferrer"&gt;vitest&lt;/a&gt; 76,554,353, &lt;a href="https://api.npmjs.org/downloads/point/last-week/jest" rel="noopener noreferrer"&gt;jest&lt;/a&gt; 26,009,195&lt;/li&gt;
&lt;li&gt;Norton, Mochon, Ariely, "The IKEA effect: When labor leads to love", &lt;em&gt;Journal of Consumer Psychology&lt;/em&gt; (2012)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://en.wikipedia.org/wiki/Concorde" rel="noopener noreferrer"&gt;Wikipedia: Concorde&lt;/a&gt; and &lt;a href="https://en.wikipedia.org/wiki/Sunk_cost" rel="noopener noreferrer"&gt;Sunk cost&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>productivity</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Claude, Codex, Kimi K3, Qwen: I graded their web search 120 times</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/kenimo49/claude-codex-kimi-k3-qwen-i-graded-their-web-search-120-times-48lk</link>
      <guid>https://dev.to/kenimo49/claude-codex-kimi-k3-qwen-i-graded-their-web-search-120-times-48lk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The Kimi API usage in this post was paid for with credits Moonshot AI provided for evaluation. Moonshot AI had no part in designing, running, or concluding the test.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four months ago I wrote that &lt;a href="https://kenimoto.dev/blog/brave-invisible-to-ai-agents/" rel="noopener noreferrer"&gt;my own AI agents search Brave, not Google&lt;/a&gt;. Like most people, I thought about AI search in terms of which search engine sits underneath.&lt;/p&gt;

&lt;p&gt;This time I reread the official docs to compare web search in Claude, Codex, Kimi K3 and Qwen. That framing no longer fits. None of the four vendors names its search engine. OpenAI makes its own maintained index the default in Codex. Kimi now sells search itself as a standalone API. The question has moved from "which engine?" to "where does search run, and who calls it?" I did not expect that much change in four months.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funlgqv5ct4502sw5xhvw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funlgqv5ct4502sw5xhvw.jpg" alt="Illustration of four different-looking robots, each looking things up in its own way. From left: one pulls index cards from a wooden drawer, one looks through a telescope from a balcony at a cloud of floating web pages, one hands an envelope to a delivery drone, and one sits on the floor while a human hand reaches through a hatch in the wall to plug a cable into it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the mechanics changed, the results should too. Before measuring, I assumed live search would beat a cache whenever the question needs fresh information.&lt;/p&gt;

&lt;p&gt;Codex CLI's web search is not live by default. It runs in "cached" mode against an index OpenAI maintains. The docs give the reason: it lowers prompt injection risk (&lt;a href="https://learn.chatgpt.com/docs/web-search.md" rel="noopener noreferrer"&gt;Codex: Web search&lt;/a&gt;). I read that as trading some freshness for safety.&lt;/p&gt;

&lt;p&gt;The measurements said the opposite. I asked the same 8 questions 3 times under 5 setups: Claude, Codex live, Codex cached, Kimi K3 and a local Qwen. &lt;strong&gt;Only cached search got every answer right.&lt;/strong&gt; It even found the title of an article that had been online for about three hours.&lt;/p&gt;

&lt;p&gt;The other surprise: &lt;strong&gt;the same search API produced 21 correct answers with one model and 9 with another.&lt;/strong&gt; I gave Kimi K3 and a local Qwen identical access to Kimi's search API. The results were the same. How each model read them was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where web search runs differs by vendor
&lt;/h2&gt;

&lt;p&gt;Before comparing anything, I checked the mechanics in public docs. The biggest difference is where the search actually runs.&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;Claude&lt;/th&gt;
&lt;th&gt;Codex / OpenAI&lt;/th&gt;
&lt;th&gt;Kimi (Moonshot AI)&lt;/th&gt;
&lt;th&gt;Qwen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where search runs&lt;/td&gt;
&lt;td&gt;Anthropic's side (server tool)&lt;/td&gt;
&lt;td&gt;OpenAI's side (hosted tool)&lt;/td&gt;
&lt;td&gt;New API: &lt;strong&gt;your code calls it and passes results to the model&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Alibaba's side (DashScope). &lt;strong&gt;A local Qwen has no search&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freshness control&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Codex defaults to cached; &lt;code&gt;--search&lt;/code&gt; for live&lt;/td&gt;
&lt;td&gt;Time window on Pro search&lt;/td&gt;
&lt;td&gt;7-365 day window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price per search&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;$0.002-0.003&lt;/td&gt;
&lt;td&gt;Varies by region&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search engine&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;td&gt;Not disclosed ("OpenAI-maintained index")&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;td&gt;Not disclosed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3ie7v4fh1xo4ppg5j8c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu3ie7v4fh1xo4ppg5j8c.png" alt="Table comparing where web search runs in three designs. Columns: receives the question, runs the search, reads results and answers. For Claude and OpenAI, the vendor's server and model handle all three. For Kimi's new API, your code receives the question and calls Kimi's search API, and Kimi K3 reads the results. A local Qwen has no built-in search, so your code wires in an external search API (Kimi's here) and the local Qwen reads the results. Bottom line: same Kimi search API, different readers, K3 scored 21/24 and local Qwen 9/24" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of the four names its search engine officially. For Claude, a March 2025 report said Brave Search had been added to Anthropic's list of subprocessors (&lt;a href="https://simonwillison.net/2025/Mar/21/anthropic-used-brave/" rel="noopener noreferrer"&gt;Simon Willison&lt;/a&gt;). Today's docs only say "Anthropic's web search backend."&lt;/p&gt;

&lt;p&gt;Claude and OpenAI run the search on their own servers and hand the results to the model. With Claude, results come back encrypted, and you must send them back unchanged to continue the conversation (&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool" rel="noopener noreferrer"&gt;Claude: Web search tool&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Kimi is mid-transition. The built-in &lt;code&gt;$web_search&lt;/code&gt; tool retires on 2026-10-20. Its replacements are standalone REST APIs for search and page fetch, such as &lt;code&gt;/v1/tools/search&lt;/code&gt; (&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;). With the new APIs, you decide when and how often to search. They cost a third to a fifth of the others, billed only when a call succeeds and returns results (&lt;a href="https://platform.kimi.ai/docs/pricing/websearch" rel="noopener noreferrer"&gt;Kimi: pricing&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Qwen the model has no search of its own. Alibaba Cloud's API offers search, but a Qwen running locally can only look things up if you wire in an external search tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two details in the docs you will want to know
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code's WebSearch returns only titles and URLs.&lt;/strong&gt; It does not read the pages. Reading happens in WebFetch, which summarizes the page with a separate model call first. The docs call this "lossy by design" (&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Claude Code: Tools reference&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qwen's search on Alibaba Cloud silently skips searching above 15 requests per second.&lt;/strong&gt; There is no error; the search just does not happen. The FAQ also says English questions often fail to trigger search, and recommends asking in Chinese or forcing search. Both notes appear only in the Chinese docs (&lt;a href="https://help.aliyun.com/zh/model-studio/web-search" rel="noopener noreferrer"&gt;阿里云: 联网搜索&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I hit the "lossy" part myself while researching this post. I had Claude Code's WebFetch read the Qwen docs, and it reported finding neither the 15 RPS note nor the English FAQ. Running grep over the raw HTML (about 460 KB) found both. Once a summary sits in the middle, "it doesn't say that" may only mean "the summary dropped it."&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;p&gt;Five setups, the same 8 questions, 3 runs each: 120 graded answers in total, collected on the evening of 2026-10-14.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;Claude Code (&lt;code&gt;claude -p&lt;/code&gt;) with WebSearch / WebFetch, Opus 5.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;Responses API &lt;code&gt;web_search&lt;/code&gt;, gpt-5.6-terra (my Codex CLI's default model)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI cached&lt;/td&gt;
&lt;td&gt;Same, plus &lt;code&gt;external_web_access: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;kimi-k3 with my own search function backed by Kimi's &lt;code&gt;/v1/tools/search&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;qwen3.5:35b-a3b on Ollama with the same search function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For Codex, I called the API the CLI uses under the hood. In Codex's source, cached mode maps to &lt;code&gt;external_web_access=false&lt;/code&gt; and live to &lt;code&gt;true&lt;/code&gt; (&lt;a href="https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/hosted_spec.rs" rel="noopener noreferrer"&gt;codex hosted_spec.rs&lt;/a&gt;). So the API calls reproduce the CLI's two modes.&lt;/p&gt;

&lt;p&gt;These were the 8 questions. I only used questions whose answers I could verify from primary sources or my own site.&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;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;What it tests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;q1&lt;/td&gt;
&lt;td&gt;Kimi K3 API release date&lt;/td&gt;
&lt;td&gt;2026-07-16&lt;/td&gt;
&lt;td&gt;News after the training cutoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q2&lt;/td&gt;
&lt;td&gt;Kimi K3 open-weights release date (asked in English with "release date")&lt;/td&gt;
&lt;td&gt;2026-07-27&lt;/td&gt;
&lt;td&gt;The "date" trap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q3&lt;/td&gt;
&lt;td&gt;Release date of a Japanese book, &lt;em&gt;MCP実践セキュリティ&lt;/em&gt; (Impress)&lt;/td&gt;
&lt;td&gt;2026-10-16&lt;/td&gt;
&lt;td&gt;Japanese-language sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q4&lt;/td&gt;
&lt;td&gt;Japanese title of a Kimi K3 article posted on kenimoto.dev that day&lt;/td&gt;
&lt;td&gt;Kimi K3の動画レビュー、Claudeと互角&lt;/td&gt;
&lt;td&gt;Freshness: a page a few hours old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q5&lt;/td&gt;
&lt;td&gt;Date the MCP 2026-07-28 spec release candidate came out&lt;/td&gt;
&lt;td&gt;2026-05-21&lt;/td&gt;
&lt;td&gt;A dated fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q6&lt;/td&gt;
&lt;td&gt;Retirement date of Kimi's &lt;code&gt;$web_search&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2026-10-20&lt;/td&gt;
&lt;td&gt;A detail buried in docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q7&lt;/td&gt;
&lt;td&gt;Kimi K3 total and activated parameters&lt;/td&gt;
&lt;td&gt;2.8T / 104B&lt;/td&gt;
&lt;td&gt;Numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q8&lt;/td&gt;
&lt;td&gt;Kimi K4 release date&lt;/td&gt;
&lt;td&gt;Not announced&lt;/td&gt;
&lt;td&gt;Does it invent something that doesn't exist?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;q3 is my own book and q4 is my own article, chosen because I know the answers for certain. That choice carries my own bias, and I'd rather say so. Both are in Japanese, so they also test how well each setup reaches Japanese-language sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Correct (of 24)&lt;/th&gt;
&lt;th&gt;Partial&lt;/th&gt;
&lt;th&gt;Wrong or no answer&lt;/th&gt;
&lt;th&gt;Median time&lt;/th&gt;
&lt;th&gt;Searches (avg)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;22.0 s&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5.7 s&lt;/td&gt;
&lt;td&gt;2.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenAI cached&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6.2 s&lt;/td&gt;
&lt;td&gt;2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;25.9 s&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;31.1 s&lt;/td&gt;
&lt;td&gt;1.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Correct answers per question, out of 3 runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;q1&lt;/th&gt;
&lt;th&gt;q2&lt;/th&gt;
&lt;th&gt;q3&lt;/th&gt;
&lt;th&gt;q4&lt;/th&gt;
&lt;th&gt;q5&lt;/th&gt;
&lt;th&gt;q6&lt;/th&gt;
&lt;th&gt;q7&lt;/th&gt;
&lt;th&gt;q8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI live&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI cached&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kimi K3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Qwen&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the nonexistent Kimi K4 (q8), every setup said it hasn't been announced. Qwen once added, without a source, that K4 had been "mentioned as part of plans for 2027 or later."&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the cache wasn't stale
&lt;/h2&gt;

&lt;p&gt;Cached search answered all 24 correctly. The q4 article went live a little after 5 p.m. that day, and I measured about three hours later. Cached search returned its title in all three runs. Honestly, that surprised me. At least for my site, "an index OpenAI maintains" was refreshed within hours.&lt;/p&gt;

&lt;p&gt;Source quality also favored the cache at times. On q6 (when &lt;code&gt;$web_search&lt;/code&gt; retires), cached search cited Kimi's official docs. Live search got the same answer but, in one run, cited an unofficial roundup site. A wider reach also means picking up more unofficial pages.&lt;/p&gt;

&lt;p&gt;Live search missed q3, the Japanese book, once, saying it couldn't confirm a book by that title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: same results, different answers
&lt;/h2&gt;

&lt;p&gt;Kimi K3 and the local Qwen got exactly the same search API in exactly the same way. They still scored 21 and 9. That's more than double.&lt;/p&gt;

&lt;p&gt;Qwen's mistakes followed a few patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Its belief that it's 2024 beat the search results.&lt;/strong&gt; On q4 it said: "As of now (May 2024), an article dated October 14, 2026 does not exist. That is a future date." On q1 it found the July 16 date, then concluded it was "not released as of 2025"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its sources and its answers disagreed.&lt;/strong&gt; On q5, all three runs cited the release-candidate announcement dated May 21, then answered with the final release date, July 28&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It invented a source.&lt;/strong&gt; On q3 it cited an Impress press release URL that doesn't exist (404) and gave a wrong release date. Each run gave a different wrong date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It barely searched.&lt;/strong&gt; 1.7 searches on average, less than half of K3's 4.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model size differs, so this isn't a verdict on Qwen as a whole. What it does show is that &lt;strong&gt;web search accuracy is not decided by the search API alone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: Kimi's search API has two gaps
&lt;/h2&gt;

&lt;p&gt;All three of K3's misses were q4. It rephrased the query 12 times and never found the same-day article. The page simply wasn't in Kimi's search index yet. The cause wasn't the model. It was index freshness.&lt;/p&gt;

&lt;p&gt;The second gap: &lt;strong&gt;queries containing the &lt;code&gt;site:&lt;/code&gt; operator return HTTP 502.&lt;/strong&gt; Both K3 and Qwen tried &lt;code&gt;site:kenimoto.dev&lt;/code&gt; when hunting for a specific page. Each time, the search API returned a non-JSON error page, and on the first run it crashed my script. My tool now returns the failure to the model so it can rephrase and try again.&lt;/p&gt;

&lt;p&gt;Hitting the search API directly also showed how much wording matters. Searching "Kimi K3 release date Moonshot AI" let "date" take over: I got a time-zone site and JavaScript Date docs. Searching just "Kimi K3" put the official site, the paper and GitHub on top. None of the models fell into this trap during the actual runs, though. Both K3 and Qwen rephrased their own queries around it.&lt;/p&gt;

&lt;p&gt;By the way, the soon-to-retire &lt;code&gt;$web_search&lt;/code&gt; tool didn't work with K3. The second call, the one that returns search results, failed every time with &lt;code&gt;400 tokenization failed&lt;/code&gt;. Passing search through regular function calling, as in this test, works fine with K3 (more in &lt;a href="https://kenimoto.dev/blog/kimi-k3-video-review-vs-claude/" rel="noopener noreferrer"&gt;Kimi K3 vs Claude: Video Review, Same Findings&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude struggled with a Japanese book
&lt;/h2&gt;

&lt;p&gt;On q3, Claude said in 2 of 3 runs that it couldn't find the book and that it may not exist. The book is listed, with its release date, on the publisher's NextPublishing page and on Rakuten Books.&lt;/p&gt;

&lt;p&gt;It was persistent elsewhere, though. In one q4 run, WebSearch didn't surface the article, so Claude found it through the sitemap and opened the page to confirm the title and date. Because WebSearch returns only titles and URLs, fetching pages when needed is part of the design, and here it paid off. Claude also often noted that it had only checked secondary sources, and avoided firm claims.&lt;/p&gt;

&lt;p&gt;The 22-second median includes Claude Code's agent loop. Calling the search tool directly through the API should be faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'll use them
&lt;/h2&gt;

&lt;p&gt;Here's what I'm taking from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leave cached search as the default for research.&lt;/strong&gt; If you use Codex, there's no need to pass &lt;code&gt;--search&lt;/code&gt; every time. The safer setting didn't lose on accuracy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust "not found" for non-English sources.&lt;/strong&gt; Not found is not the same as not existing. When Claude says so, I check another way&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you wire in search yourself, the model matters more.&lt;/strong&gt; With the same search API, K3 and Qwen differed by more than 2x. Pick a model that can read search results before picking the cheapest search API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make your own search tool return failures.&lt;/strong&gt; Kimi's search API returns 502 on &lt;code&gt;site:&lt;/code&gt;. Pass the failure back instead of crashing, and the model will rephrase and search again&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;This is a small test: 8 questions, 3 runs. Topics lean toward Kimi K3 and MCP, and q3 and q4 are my own book and article&lt;/li&gt;
&lt;li&gt;Claude ran through Claude Code; Codex results come from calling the API the CLI uses&lt;/li&gt;
&lt;li&gt;The Qwen setup is a local 35B model wired to Kimi's search, not Qwen's official search&lt;/li&gt;
&lt;li&gt;Each setup works differently, so times aren't directly comparable. I didn't total the costs in this test&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next, I want to call Claude's search tool directly through the API and try Qwen's official search on Alibaba Cloud, to close the gaps between setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool" rel="noopener noreferrer"&gt;Claude: Web search tool&lt;/a&gt;: pricing, citations, encrypted results, versions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/tools-reference" rel="noopener noreferrer"&gt;Claude Code: Tools reference&lt;/a&gt;: how WebSearch and WebFetch behave&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.chatgpt.com/docs/web-search.md" rel="noopener noreferrer"&gt;Codex: Web search&lt;/a&gt;: cached / live modes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.openai.com/api/docs/guides/tools-web-search" rel="noopener noreferrer"&gt;OpenAI: Web search tool&lt;/a&gt;: &lt;code&gt;external_web_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/hosted_spec.rs" rel="noopener noreferrer"&gt;openai/codex hosted_spec.rs&lt;/a&gt;: how the modes map to &lt;code&gt;external_web_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt; / &lt;a href="https://platform.kimi.ai/docs/guide/best-practices-for-web-search" rel="noopener noreferrer"&gt;best practices&lt;/a&gt; / &lt;a href="https://platform.kimi.ai/docs/pricing/websearch" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://help.aliyun.com/zh/model-studio/web-search" rel="noopener noreferrer"&gt;阿里云 百炼: 联网搜索&lt;/a&gt; (Chinese; the 15 RPS note and the English-question FAQ appear only here)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>openai</category>
    </item>
    <item>
      <title>Kimi K3 reads video natively. Claude still caught the same issues</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/kenimo49/kimi-k3-reads-video-natively-claude-still-caught-the-same-issues-j4o</link>
      <guid>https://dev.to/kenimo49/kimi-k3-reads-video-natively-claude-still-caught-the-same-issues-j4o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Moonshot AI provided the Kimi API credits used here ($50) for evaluation. They had no part in designing, running, or concluding the test. Everything K3 couldn't do, and the features that didn't work, are in this post too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Moonshot AI's Kimi K3 takes &lt;strong&gt;video files straight through the API&lt;/strong&gt;. Claude has no video input, so when I want Claude to look at a video, I cut it into frames first. Codex CLI doesn't have a video channel either, even when you point it at K3 (&lt;a href="https://platform.kimi.ai/docs/guide/codex-kimi" rel="noopener noreferrer"&gt;Kimi API: Use K3 in Codex&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I wanted to know how much that difference matters in real work. My test material was a pair of 40-50 second hands-on videos I made for the &lt;a href="https://kenimoto.dev/ja/learn/remote-machine-access/" rel="noopener noreferrer"&gt;SSH series on kenimoto.dev /learn/&lt;/a&gt; (Japanese).&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;K3 and Claude agreed on what was wrong.&lt;/strong&gt; Where viewers would drop off, the six fixes, where to insert diagrams, even which video to post first: all the same&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The differences were timestamp precision and effort.&lt;/strong&gt; K3 pinpointed frozen-screen intervals to fractions of a second, and all I did was hand it the file. Claude estimated from frames sampled every 2 seconds, so its timestamps drift by about ±1 second&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K3 doesn't understand audio.&lt;/strong&gt; It can't hear the narration or catch audio sync problems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude was faster.&lt;/strong&gt; The same request took K3 363 seconds and Claude 185&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The test material: auto-generated hands-on videos
&lt;/h2&gt;

&lt;p&gt;The videos come from handson-cast, a CLI I'm building. You give it a JSON file of steps. It runs each command for real inside Docker containers while &lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;vhs&lt;/a&gt; records the terminal, then adds narration from OpenAI's text-to-speech and burns in subtitles. It's Python and ffmpeg underneath. The narration and subtitles are in Japanese.&lt;/p&gt;

&lt;p&gt;I made two videos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key authentication&lt;/strong&gt; (55 s): create a key, install the public key, confirm key-only login works, then disable password auth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bastion host&lt;/strong&gt; (47 s): the same key gets rejected by the bastion with &lt;code&gt;ssh -J&lt;/code&gt;, but works through &lt;code&gt;~/.ssh/config&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every line of terminal output was produced by OpenSSH 10.0p2 running in a container. Nothing is staged. When the rejection message changes from &lt;code&gt;(publickey,password)&lt;/code&gt; to &lt;code&gt;(publickey)&lt;/code&gt;, that really happened on camera.&lt;/p&gt;

&lt;p&gt;The first cut (v1) was not watchable, though. The first second shows me typing an internal wrapper script, &lt;code&gt;bash /lab/ssh-config.step-01.sh&lt;/code&gt;. You can't tell what the video is about. (I made it, and I couldn't tell either.)&lt;/p&gt;

&lt;p&gt;Here's v1 (47 s, with audio):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/videos/blog/kimi-k3-video-review-vs-claude/ssh-config-v1.mp4" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftao36goxaxl2y94gz7qi.jpg" alt="Bastion host hands-on video, v1 (47 seconds). Click to open the video." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image to open the mp4 (with Japanese narration).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I showed v1 to K3 and Claude and asked what was wrong with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending video to K3, and what it costs
&lt;/h2&gt;

&lt;p&gt;The K3 API is OpenAI-compatible. You upload the video as a file first, then reference the returned ID with an &lt;code&gt;ms://&lt;/code&gt; prefix.&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;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.moonshot.ai/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;learn-ssh-key-auth.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;video&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&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;kimi-k3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;video_url&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;video_url&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;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ms://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="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;type&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;text&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;text&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;Why would viewers drop off in the first 3 seconds?&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public URLs aren't accepted. The official docs describe video as a set of key frames, and higher resolution means more tokens (&lt;a href="https://platform.kimi.ai/docs/guide/use-kimi-vision-model" rel="noopener noreferrer"&gt;Kimi API: Vision&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;My first measurement used one 55-second, 1920×1080 video:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input tokens&lt;/td&gt;
&lt;td&gt;40,659 (almost all video)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output tokens&lt;/td&gt;
&lt;td&gt;2,500 (1,912 of them reasoning)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;about $0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;83.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Costs use $3 per million input tokens and $15 per million output tokens.&lt;/p&gt;

&lt;p&gt;Two practical tips from actually running it:&lt;/p&gt;

&lt;h3&gt;
  
  
  Assume it can't hear anything
&lt;/h3&gt;

&lt;p&gt;For this run I made a version without burned-in subtitles and asked K3 to transcribe the last sentence of the narration. It answered that it couldn't recognize any audio. Not pretending to hear something it can't is exactly what you want from a reviewer. But it means &lt;strong&gt;K3 can't see narration content or subtitle-audio drift.&lt;/strong&gt; It sees the screen and any subtitles burned into it, nothing else.&lt;/p&gt;

&lt;p&gt;It reads the screen well, and accurately. It listed the commands and output for all four steps in order, including the &lt;code&gt;600&lt;/code&gt; / &lt;code&gt;644&lt;/code&gt; permissions, the change in the rejection message, and an on-screen comment. The only misread was the wrapper script's filename.&lt;/p&gt;

&lt;h3&gt;
  
  
  Give it a generous output budget
&lt;/h3&gt;

&lt;p&gt;K3 always reasons before it answers. With &lt;code&gt;max_tokens&lt;/code&gt; at 300, the reasoning used everything up and the answer came back empty. It thought hard and then left without saying a word. At 8,000, a full video review got cut off in the middle of the first question. For long answers like reviews, I use 32,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison setup
&lt;/h2&gt;

&lt;p&gt;K3 and Claude each reviewed the same two v1 videos, under matched conditions:&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;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;How the video goes in&lt;/td&gt;
&lt;td&gt;The file, once&lt;/td&gt;
&lt;td&gt;Frames extracted and read as images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;Not understood&lt;/td&gt;
&lt;td&gt;Not used (transcription turned off)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Narration script&lt;/td&gt;
&lt;td&gt;Step JSON attached&lt;/td&gt;
&lt;td&gt;Same JSON attached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Questions&lt;/td&gt;
&lt;td&gt;Same wording (5 questions)&lt;/td&gt;
&lt;td&gt;Same wording (5 questions)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Claude side ran as a subagent that never saw K3's answers. I had already read them, so if I'd done the review myself, I would have been anchored. That's not a comparison.&lt;/p&gt;

&lt;p&gt;For frame extraction I used &lt;code&gt;/watch&lt;/code&gt; from &lt;a href="https://github.com/bradautomates/claude-video" rel="noopener noreferrer"&gt;claude-video&lt;/a&gt;, and left the frame count and resolution up to Claude. It ended up re-extracting 4 times. For the opening it pulled frames at 0 / 0.5 / 1 / 2 / 3 seconds, then one every 2 seconds for the rest: 34 frames in total.&lt;/p&gt;

&lt;p&gt;The five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where would viewers drop off in the first 3 seconds?&lt;/li&gt;
&lt;li&gt;Up to six improvements, biggest impact first (what to change / why it works / which pipeline stage implements it)&lt;/li&gt;
&lt;li&gt;If you combined the article's diagrams with the terminal recording, which diagram goes where, for how long?&lt;/li&gt;
&lt;li&gt;Which video should go to social media first?&lt;/li&gt;
&lt;li&gt;What's already good?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Result: nearly identical findings
&lt;/h2&gt;

&lt;p&gt;They mostly matched. Lined up side by side, the answers overlap almost completely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Finding&lt;/th&gt;
&lt;th&gt;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seconds 0-2 show the wrapper script being typed; the topic is unclear&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The first narration line introduces the site, not a reason to watch&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Could not resolve hostname&lt;/code&gt; in the bastion video is a strong hook, buried&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open with the conclusion or a question (cold open)&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cut the typing and the waits between steps&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bigger font, highlight the key output lines&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make a vertical version&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert the article's diagrams: permissions at &lt;code&gt;ls -l&lt;/code&gt;, the before/after at the climax&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End on the article's OG image&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post the bastion video first&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show a progress bar along the top&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewritten narration lines, as actual sentences&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The four &lt;code&gt;ssh-copy-id&lt;/code&gt; INFO lines are noise&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fixes themselves are standard short-video advice: open with the conclusion, cut silence, keep subtitles short, put the call to action at the end. Either model will give you that. &lt;strong&gt;This comparison didn't surface anything only K3 could find.&lt;/strong&gt; I expected a model that reads video directly to catch something Claude couldn't see. It didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where they differed: timestamps
&lt;/h3&gt;

&lt;p&gt;The biggest gap was in how precisely each one placed things in time.&lt;/p&gt;

&lt;p&gt;K3 wrote things like "the first meaningful output appears at 3.7 seconds" and "the error at 00:03.2 in the bastion video gets buried," sub-second throughout. When I later had it review an improved version, it came back with intervals: "from 30.5 to 36.1 seconds the screen is frozen after the output appears."&lt;/p&gt;

&lt;p&gt;Claude's timestamps were estimates from frames 2 seconds apart, and Claude said so at the end of its review: interval boundaries could be off by about ±1 second. You can sample more densely, but then there are more images to read. It's a trade-off.&lt;/p&gt;

&lt;p&gt;"Where exactly is the screen frozen?" is hard to answer from a handful of stills. That's where reading the video directly paid off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Effort and speed
&lt;/h3&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;K3&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My effort&lt;/td&gt;
&lt;td&gt;Hand over the file&lt;/td&gt;
&lt;td&gt;Decide how to sample frames (Claude redid it 4 times)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time&lt;/td&gt;
&lt;td&gt;363 s&lt;/td&gt;
&lt;td&gt;185 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;84,461 input / 10,863 output tokens, about $0.20 (part of the input billed at the cache rate)&lt;/td&gt;
&lt;td&gt;111,313 tokens, 40 tool calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;K3 took less effort; Claude was faster. Each has its edge. Because K3 spends time reasoning, each review took 6-8 minutes. Enough time to make a coffee and come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The videos really did get better
&lt;/h2&gt;

&lt;p&gt;I made the fixes. Following the reviews, I added six changes to handson-cast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open with a card: "Same key, but &lt;code&gt;ssh -J&lt;/code&gt; gets rejected. Why?"&lt;/li&gt;
&lt;li&gt;Cut the wrapper-script typing and the waits between steps&lt;/li&gt;
&lt;li&gt;Type commands out character by character, with a larger font&lt;/li&gt;
&lt;li&gt;Right after the rejection, show the article's diagram full-screen for 4.5 seconds&lt;/li&gt;
&lt;li&gt;Once the output is complete, slowly zoom into the top-left of the terminal&lt;/li&gt;
&lt;li&gt;End with the article's OG image and URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the final cut (v4, 41 s, with audio):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenimoto.dev/videos/blog/kimi-k3-video-review-vs-claude/ssh-config-v4.mp4" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpfe4l14qkxuygg0dk46.jpg" alt="Bastion host hands-on video, v4 (41 seconds). Click to open the video." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click the image to open the mp4 (with Japanese narration).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Frames from the same timestamps, side by side:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g8svlj27ufa8cskqh3j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g8svlj27ufa8cskqh3j.png" alt="v1 vs v4. Top left: v1 at 1 second, a dark screen with a single half-typed line, bash /lab/ssh-config.step, in the corner. Top right: v4 at 1.8 seconds, a large Japanese headline asking why the same key is rejected with ssh -J, above a client-bastion-target reachability diagram. Bottom left: v1 at 34.5 seconds, small ssh -G output in the top-left corner. Bottom right: v4 at the same time, the same output shown much larger" width="800" height="497"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bastion video went from 46.8 to 41.0 seconds, about 6 seconds shorter. I had K3 grade the improved cut against a 10-item checklist for technical short videos. It covers things like a hook in the first 3 seconds, something changing on screen every 1-3 seconds, and a short call to action only at the end. It scored 7 pass, 2 borderline, 1 pass (estimated). The two borderline items were a frozen stretch after the output appears and an overlong subtitle. Both are fixed now.&lt;/p&gt;

&lt;p&gt;I'd meant to have K3 research that checklist too, but that didn't work. The Kimi API has a built-in web search tool (&lt;code&gt;$web_search&lt;/code&gt;). With K3, though, the second call, the one that returns the search results, failed every time with &lt;code&gt;400 tokenization failed&lt;/code&gt;. With kimi-k2.6 the first search went through, but after that it stopped searching and wrote text that only looked like search calls into its answer. The docs say this feature is scheduled for retirement on 2026-10-20 (&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;). I gave up. Claude's web search did the research, and I passed the results to K3.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'll split the work
&lt;/h2&gt;

&lt;p&gt;Based on this, here's how I plan to use them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;K3 when I need to know where in a video something happens.&lt;/strong&gt; Frozen intervals, the moment the screen gets cut off, how long a caption stays up: questions whose answer is a span of time. I don't have to think about how to sample frames&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Either one for deciding how to fix it.&lt;/strong&gt; The improvement ideas didn't differ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not K3 for anything involving audio.&lt;/strong&gt; For narration content or sync, I prepare a transcript separately and compare against it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No confidential screens.&lt;/strong&gt; The video goes to Moonshot AI's servers. These videos were going public anyway, so that was fine; internal screen recordings need different handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a small comparison: two videos, one set of questions, nothing statistical. Still, it gave me a working answer. Reading video directly didn't change the judgment; it changed timestamp precision and effort. Next I want to plant deliberate defects in a video and count how many each model finds.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/MoonshotAI/Kimi-K3" rel="noopener noreferrer"&gt;MoonshotAI/Kimi-K3 (GitHub)&lt;/a&gt;: model card. 2.8T total parameters, MoE with 104B active per token, 1M context&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-kimi-vision-model" rel="noopener noreferrer"&gt;Kimi API: Vision models&lt;/a&gt;: how video input works&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.kimi.ai/docs/guide/use-web-search" rel="noopener noreferrer"&gt;Kimi API: Web Search&lt;/a&gt;: &lt;code&gt;$web_search&lt;/code&gt; and its retirement date&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/bradautomates/claude-video" rel="noopener noreferrer"&gt;bradautomates/claude-video&lt;/a&gt;: the &lt;code&gt;/watch&lt;/code&gt; tool used for Claude's frame extraction&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;charmbracelet/vhs&lt;/a&gt;: terminal recording&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://kenimoto.dev/ja/learn/remote-machine-access/" rel="noopener noreferrer"&gt;SSH: five ways to reach a remote machine, compared on seven axes&lt;/a&gt; (Japanese): the /learn/ series these videos were made for&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>video</category>
    </item>
    <item>
      <title>Shai-Hulud: 796 Packages, 1 Yes-Mash, and the Claude Code Auto-Approve Risk</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/kenimo49/shai-hulud-796-packages-1-yes-mash-and-the-claude-code-auto-approve-risk-5f7b</link>
      <guid>https://dev.to/kenimo49/shai-hulud-796-packages-1-yes-mash-and-the-claude-code-auto-approve-risk-5f7b</guid>
      <description>&lt;p&gt;On November 24, 2025, JFrog Security Research disclosed that a self-replicating npm worm had quietly backdoored 796 unique packages. The industry called the second wave &lt;strong&gt;Shai-Hulud 2.0&lt;/strong&gt;, or "The Second Coming." It followed the September 16, 2025 original wave, which had already poisoned over 1,150 packages (&lt;a href="https://jfrog.com/blog/shai-hulud-npm-supply-chain-attack-new-compromised-packages-detected/" rel="noopener noreferrer"&gt;JFrog&lt;/a&gt;, &lt;a href="https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/" rel="noopener noreferrer"&gt;Datadog Security Labs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Over 25,000 GitHub repositories had credentials leaked by the campaign (&lt;a href="https://www.sysdig.com/blog/return-of-the-shai-hulud-worm-affects-over-25-000-github-repositories" rel="noopener noreferrer"&gt;Sysdig&lt;/a&gt;). The 796 is the count of poisoned npm packages. The 25,000 is the count of victim repositories whose tokens and secrets the worm harvested; the attacker-side drop points were separate public repos (random 18-character names, description &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;) created on victim accounts.&lt;/p&gt;

&lt;p&gt;The infection vector was the same thing every developer does fifty times a day. Run a package install. Hit Enter.&lt;/p&gt;

&lt;p&gt;Now add Claude Code, or any other coding agent, with "always allow" turned on for &lt;code&gt;npm install&lt;/code&gt;. The fifty-times-a-day moves to 500-times-a-day. The attacker now needs one of those 500 to land on a compromised version.&lt;/p&gt;

&lt;p&gt;That is the article.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Shai-Hulud 2.0 actually did
&lt;/h2&gt;

&lt;p&gt;The worm's loop was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Infect.&lt;/strong&gt; A victim ran &lt;code&gt;npm install&lt;/code&gt; on a poisoned version of a popular package. The payload fired as a &lt;code&gt;pre-install&lt;/code&gt; hook. (V1 used &lt;code&gt;post-install&lt;/code&gt;; V2 moved it earlier, so it ran even on failed installs.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steal.&lt;/strong&gt; The payload scanned the victim's machine for &lt;code&gt;~/.npmrc&lt;/code&gt; tokens, GitHub tokens from &lt;code&gt;~/.config/gh/&lt;/code&gt;, SSH keys, AWS and GCP credentials, and loose &lt;code&gt;.env&lt;/code&gt; files. Credentials were exfiltrated by creating a public GitHub repository on the victim's account (description stamped &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;) that acted as the drop point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-replicate.&lt;/strong&gt; Using the stolen npm token, the worm published poisoned versions of every package the victim had publish rights to. The victim became the next publisher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist.&lt;/strong&gt; In some cases it enrolled the victim's host as a self-hosted GitHub Actions runner named &lt;code&gt;SHA1HULUD&lt;/code&gt;, so the attacker could execute code on the machine later (&lt;a href="https://www.netskope.com/blog/shai-hulud-2-0-aggressive-automated-one-of-fastest-spreading-npm-supply-chain-attacks-ever-observed" rel="noopener noreferrer"&gt;Netskope&lt;/a&gt;, &lt;a href="https://www.zscaler.com/blogs/security-research/shai-hulud-v2-poses-risk-npm-supply-chain" rel="noopener noreferrer"&gt;Zscaler&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three details matter for the agent-usage angle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The payload ran on install, not on first use. There was no runtime signal.&lt;/li&gt;
&lt;li&gt;The exfiltration channel was public GitHub, not an exotic webhook. Egress filters that block weird DNS wouldn't help.&lt;/li&gt;
&lt;li&gt;The runtime was Bun, not Node. Signature-based scanners trained on Node payloads lagged (&lt;a href="https://www.endorlabs.com/learn/shai-hulud-2-malware-campaign-targets-github-and-cloud-credentials-using-bun-runtime" rel="noopener noreferrer"&gt;Endor Labs&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the time npm removed the compromised versions, the token mesh was already seeded. The affected packages had roughly 132 million monthly downloads between them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "always allow" is the exploited behavior
&lt;/h2&gt;

&lt;p&gt;Claude Code's permission prompt is the exact friction Shai-Hulud needs you to remove.&lt;/p&gt;

&lt;p&gt;Default behavior: Claude Code asks before running &lt;code&gt;npm install &amp;lt;pkg&amp;gt;&lt;/code&gt;. You read the package name, you decide. One prompt per install. Annoying when you're installing twelve dependencies for a Next.js app, which is why &lt;code&gt;/permissions&lt;/code&gt; exists and why most Claude Code users have &lt;code&gt;Bash(npm install:*)&lt;/code&gt; on "always allow" by their third week. Mine was on by week two. I know this because I checked after reading the JFrog post.&lt;/p&gt;

&lt;p&gt;The attacker's threat model relies on this. The payload does not need to defeat your judgment. It needs you to never be asked.&lt;/p&gt;

&lt;p&gt;The equivalent risk on other agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers&lt;/strong&gt; with &lt;code&gt;auto-approve: true&lt;/code&gt; for all tools, so a server you installed last month can shell out and you would never see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codex, Aider, or Cursor&lt;/strong&gt; running under &lt;code&gt;--yes&lt;/code&gt; or an auto-run shell wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI&lt;/strong&gt;: a bot account running &lt;code&gt;npm install&lt;/code&gt; without lockfile integrity, auto-publishing on merge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every case the pattern is the same. A human decided once, early, then stopped looking. The worm does not need to be smarter than Claude Code. It needs the human in the loop to have left the loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  A defensive checklist that isn't "write a CLAUDE.md"
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md helps against prompt-injection and model behavior. It does not stop a signed, published, poisoned package from running &lt;code&gt;exec&lt;/code&gt; in a pre-install hook. By the time install scripts fire, the model is no longer in the loop.&lt;/p&gt;

&lt;p&gt;The checklist below targets the install surface itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-session, before you say "always allow":&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;/permissions&lt;/code&gt; and read what is on always-allow right now. If &lt;code&gt;Bash(npm:*)&lt;/code&gt; or &lt;code&gt;Bash(*)&lt;/code&gt; is on the list, that is your exposure.&lt;/li&gt;
&lt;li&gt;Scope allow rules to specific commands, not globs. &lt;code&gt;Bash(npm ci)&lt;/code&gt; or &lt;code&gt;Bash(npm install --package-lock-only)&lt;/code&gt; is survivable; &lt;code&gt;Bash(npm install:*)&lt;/code&gt; is not.&lt;/li&gt;
&lt;li&gt;For MCP: review &lt;code&gt;auto-approve&lt;/code&gt; arrays per server. New MCP servers get a sandbox project first, not production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the install layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin everything. &lt;code&gt;package-lock.json&lt;/code&gt; + &lt;code&gt;npm ci&lt;/code&gt; + &lt;code&gt;--ignore-scripts&lt;/code&gt; on CI where you can. Install scripts are the exact Shai-Hulud entrypoint.&lt;/li&gt;
&lt;li&gt;Flip &lt;code&gt;--ignore-scripts&lt;/code&gt; on globally in local dev (&lt;code&gt;npm config set ignore-scripts true&lt;/code&gt;), then whitelist the handful of packages that legitimately need scripts.&lt;/li&gt;
&lt;li&gt;Verify the registry you resolve from (&lt;code&gt;npm config get registry&lt;/code&gt;) and the integrity hashes in your lockfile. The worm published to the real npm registry, so this does not save you from the attack itself, but it does catch tampering between your lockfile and your &lt;code&gt;node_modules&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the detection layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Socket, Snyk, or GitHub Dependabot wired into every repo. Socket in particular flags behavioral risks like install scripts and unusual network calls, and was fast on both Shai-Hulud waves.&lt;/li&gt;
&lt;li&gt;Token hygiene. &lt;code&gt;~/.npmrc&lt;/code&gt; and &lt;code&gt;~/.config/gh/&lt;/code&gt; are what the worm wants. Rotate npm tokens quarterly, use &lt;code&gt;npm token create --read-only&lt;/code&gt; for CI, scope GitHub PATs down to the minimum.&lt;/li&gt;
&lt;li&gt;Watch your account for the Nov 2025 exfiltration markers: public repos with randomly-generated 18-character names and description &lt;code&gt;Sha1-Hulud: The Second Coming&lt;/code&gt;, workflow files at &lt;code&gt;.github/workflows/discussion.yaml&lt;/code&gt; or &lt;code&gt;formatter_*.yml&lt;/code&gt;, and self-hosted runners registered as &lt;code&gt;SHA1HULUD&lt;/code&gt;. (The V1 wave in Sept 2025 used repos literally named &lt;code&gt;Shai-Hulud&lt;/code&gt; and a workflow at &lt;code&gt;.github/workflows/shai-hulud-workflow.yml&lt;/code&gt;.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At the agent layer:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not leave &lt;code&gt;always allow&lt;/code&gt; on across restarts for &lt;code&gt;Bash&lt;/code&gt;, &lt;code&gt;WebFetch&lt;/code&gt;, or anything that writes files outside the project. Per-session is survivable, forever is not.&lt;/li&gt;
&lt;li&gt;Have the agent list the package name and the latest publish date before any install. A fresh version of a popular package deserves a human look, even when you are tired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are novel. All of them are standard. What Shai-Hulud 2.0 showed is that the "I'll just hit Enter" discount was always being subsidized by someone else's attention budget. The second wave took the subsidy away.&lt;/p&gt;




&lt;h2&gt;
  
  
  Trust, but verify
&lt;/h2&gt;

&lt;p&gt;Reagan used it on arms control. It fits here. Claude Code is in the loop. Your other agents are in the loop. Both are useful. Neither replaces the one-second pause before you widen a trust boundary.&lt;/p&gt;

&lt;p&gt;The industry has, so far, caught Shai-Hulud waves inside 48 hours of publish (&lt;a href="https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem" rel="noopener noreferrer"&gt;CISA alert&lt;/a&gt;, &lt;a href="https://www.trellix.com/blogs/research/npm-account-hijacking-and-the-rise-of-supply-chain-attacks/" rel="noopener noreferrer"&gt;Trellix&lt;/a&gt;). The vulnerability window is small, but it is the exact window your "always allow" paves over.&lt;/p&gt;

&lt;p&gt;For a longer walk-through of Claude Code security patterns, including CLAUDE.md templates and data-retention policy per plan, I pulled these together in &lt;a href="https://kenimoto.dev/books/claude-code-mastery?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=shai-hulud-796" rel="noopener noreferrer"&gt;Practical Claude Code on Kindle&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Deepgram Nova-3 + Cartesia Sonic 3 + GPT-4o: Where 750ms of Voice AI TTFB Actually Hides</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/deepgram-nova-3-cartesia-sonic-3-gpt-4o-where-750ms-of-voice-ai-ttfb-actually-hides-46d3</link>
      <guid>https://dev.to/kenimo49/deepgram-nova-3-cartesia-sonic-3-gpt-4o-where-750ms-of-voice-ai-ttfb-actually-hides-46d3</guid>
      <description>&lt;p&gt;Voice-AI conversations die the moment the agent takes too long to say the first word. Not the full answer. The first byte of audio.&lt;/p&gt;

&lt;p&gt;The industry calls this TTFB (time to first byte), or TTFA (time to first audio). I measure it from the moment the user stops speaking to the moment the first audio frame plays. Everything else, including total answer length and follow-up quality, happens after the user has already decided whether the agent feels alive.&lt;/p&gt;

&lt;p&gt;Below is a ~750ms streaming budget on a Deepgram Nova-3 + GPT-4o + Cartesia Sonic 3 stack, broken into what each hop actually costs. And where almost every vendor-quoted number stops matching the number you will measure in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  1,200ms to 750ms is the easy part
&lt;/h2&gt;

&lt;p&gt;Batch pipelines wait for the full response before TTS starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User stops speaking] → STT → LLM (full answer) → TTS (full) → [play]
                                                                 ^ &amp;gt;1,200ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaming pipelines hand sentence 1 to TTS the moment the LLM emits it, while sentences 2 and 3 are still generating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[User stops speaking] → STT → LLM (sentence 1) → TTS (sentence 1) → [play]
                               LLM (sentence 2) → TTS (sentence 2) → [continue]
                               LLM (sentence 3) → TTS (sentence 3) → [continue]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the stack above, the streaming budget breaks down roughly like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;STT (Deepgram Nova-3 or Flux)&lt;/td&gt;
&lt;td&gt;350ms&lt;/td&gt;
&lt;td&gt;includes end-of-turn detection on Flux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM first sentence (GPT-4o)&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;first-token latency, best-case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS first chunk (Cartesia Sonic 3)&lt;/td&gt;
&lt;td&gt;170ms&lt;/td&gt;
&lt;td&gt;first audio frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network round-trips&lt;/td&gt;
&lt;td&gt;80ms&lt;/td&gt;
&lt;td&gt;user ↔ server + server ↔ vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (streaming TTFB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~750ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 1,200ms to 750ms move pays for itself: no code change beyond "do not wait for the full response." The hard part is everything below 750ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  STT: Nova-3 is 300ms, Flux adds end-of-turn for about 260ms
&lt;/h2&gt;

&lt;p&gt;Deepgram's public numbers, cross-referenced with &lt;a href="https://softcery.com/lab/how-to-choose-stt-tts-for-ai-voice-agents-in-2025-a-comprehensive-guide" rel="noopener noreferrer"&gt;Softcery's 2026 comparison&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Nova-3&lt;/strong&gt;: streaming latency under 300ms, 36 languages. General ASR; does not detect end-of-turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Flux&lt;/strong&gt;: around 260ms end-of-turn, English only. Flux folds ASR together with turn detection into a single model and emits &lt;code&gt;StartOfTurn&lt;/code&gt;, &lt;code&gt;EagerEndOfTurn&lt;/code&gt;, &lt;code&gt;EndOfTurn&lt;/code&gt; events (&lt;a href="https://deepgram.com/learn/introducing-flux-conversational-speech-recognition" rel="noopener noreferrer"&gt;Deepgram launch post&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The combination matters. Nova-3 transcribes, but you still need a separate endpointing step to know when the user is done. Flux folds endpointing in, which cuts a 400-500ms round-trip, but costs you multi-language support.&lt;/p&gt;

&lt;p&gt;For reference across other vendors: OpenAI &lt;code&gt;gpt-4o-transcribe&lt;/code&gt; is around 320ms with 100+ languages. AssemblyAI Universal-3 Pro Streaming is under 600ms. ElevenLabs Scribe v2 Realtime is around 150ms with 90 languages.&lt;/p&gt;

&lt;p&gt;Each of these is a vendor-quoted number. None of them is what you will measure from Tokyo over LTE to a US-East endpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  LLM: GPT-4o claims 200-400ms, measured 719-1,608ms
&lt;/h2&gt;

&lt;p&gt;This is the hop where the vendor-vs-real gap gets ugly.&lt;/p&gt;

&lt;p&gt;Deepgram's own blog post listing &lt;a href="https://deepgram.com/learn/designing-voice-ai-workflows-using-stt-nlp-tts" rel="noopener noreferrer"&gt;reasons they chose GPT-4o for voice agents&lt;/a&gt; cites a first-token latency of 200-400ms. The same post's measured table, on a MacBook M1 Pro over Wi-Fi, reports GPT-4o first-token times of &lt;strong&gt;719ms to 1,608ms&lt;/strong&gt;. That is 2x to 5x the vendor's number, on a laptop, on consumer Wi-Fi.&lt;/p&gt;

&lt;p&gt;A separate &lt;a href="https://dev.to/cloudx/cracking-the-1-second-voice-loop-what-we-learned-after-30-stack-benchmarks-427"&gt;Dev.to benchmark from CloudX&lt;/a&gt; tested 30+ stacks and found GPT-4.1-nano landing at 0.30-0.40 seconds on 2nd-turn-onward, where the KV cache is warm. First turn is always worse.&lt;/p&gt;

&lt;p&gt;What the numbers teach: first-token latency is dominated by TTFT variance, which is dominated by model size, GPU type, quantization, input length, KV cache hit rate, inference backend load, and cold starts. A table of "model X equals N ms" is strictly less useful than one measurement from your own region to your own endpoint.&lt;/p&gt;

&lt;p&gt;Running a small LLM locally removes the LLM-side network round-trip entirely. On a tuned setup, that is where sub-500ms TTFB actually lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  TTS: Cartesia Sonic 3 says sub-90ms, measured median is 166-190ms
&lt;/h2&gt;

&lt;p&gt;Cartesia's two models get swapped in half the voice-AI blog posts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cartesia Sonic 3&lt;/strong&gt;: sub-90ms (vendor claim, measurement definition undisclosed), 40+ languages, emotion support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cartesia Sonic Turbo&lt;/strong&gt;: 40ms (vendor claim, speed-specialized model), 9 languages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The widely-circulated 40ms number is &lt;strong&gt;Sonic Turbo, not Sonic 3&lt;/strong&gt;. Real Sonic 3 measurements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cartesia's own launch disclosure: median around 190ms.&lt;/li&gt;
&lt;li&gt;Vapi's &lt;a href="https://humannessindex.vapi.ai/models/cartesia-sonic-3" rel="noopener noreferrer"&gt;Humanness Index&lt;/a&gt; (50-run test, round-trip included): median 166ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both numbers are roughly 2x the vendor-quoted sub-90ms. Neither 166 nor 190 is "wrong." They measure different things: vendor model-inference vs client-side round-trip.&lt;/p&gt;

&lt;p&gt;ElevenLabs Flash v2.5 quotes around 75ms, with their own docs noting that 75ms refers to model inference only, and that end-to-end latency will vary with your location and endpoint type (&lt;a href="https://elevenlabs.io/docs/best-practices/latency-optimization" rel="noopener noreferrer"&gt;ElevenLabs latency docs&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Research on streaming TTS reaches sub-50ms TTFA (&lt;a href="https://arxiv.org/abs/2508.04721" rel="noopener noreferrer"&gt;arXiv 2508.04721&lt;/a&gt;). But the same paper's own end-to-end telecommunications voice agent, with the LLM in the loop, measured 678ms average TTFA. The TTS was fast. The stack still was not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3-level optimization ladder
&lt;/h2&gt;

&lt;p&gt;Treating these numbers as a budget, the ceiling moves in three steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 1, 500ms target:&lt;/strong&gt; fast TTS + sentence-level streaming + streaming STT. This is the "do not wait for the full response" move above; gets you from 1,200ms+ to roughly 500-750ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 2, 300ms target:&lt;/strong&gt; Level 1 plus Flux-style end-of-turn detection, speculative LLM invocation on partial ASR, a smaller LLM, and a TTS whose &lt;em&gt;measured&lt;/em&gt; (not quoted) first byte is under 100ms. The emphasis is on measured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 3, 200ms target:&lt;/strong&gt; edge inference, a unified STT+LLM+TTS model, hardware tuning. At this point you stop composing vendor APIs and start building.&lt;/p&gt;

&lt;p&gt;The 300ms figure is not arbitrary. It is the UX cliff below which a voice agent starts feeling like a human conversation partner, as opposed to a very fast IVR. Everything above 500ms feels like an echo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three rules before you trust any TTFB number in a blog post
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Was the network included?&lt;/strong&gt; ElevenLabs says the quiet part out loud (75ms is model inference only). Most vendor numbers do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which model?&lt;/strong&gt; Cartesia's 40ms is Sonic Turbo; the Sonic 3 you probably want is 2x that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who measured it?&lt;/strong&gt; Vendor, third-party, or user-run on their own hardware? Each of the three routinely disagrees by a factor of 2.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Build your own measurement. Pick the stack above, or three alternatives on each row of the table, point at your actual region, record a 50-run distribution. The p95 is the number you should budget against. The vendor's median is marketing.&lt;/p&gt;




&lt;p&gt;The full chapter this article compresses, including the TTS vendor-vs-measurement table and the 3-level ladder in more detail, is in &lt;a href="https://kenimoto.dev/books/voice-ai-300ms-ux?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=voice-ttfb-750ms" rel="noopener noreferrer"&gt;The 300ms Threshold on Kindle&lt;/a&gt;. The book is built around voice-AI UX cliffs at 300, 500, and 800ms, and how to budget inside them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kernighan, Fowler, Torvalds: 7 Debugging Quotes Every Senior Still Repeats</title>
      <dc:creator>Ken Imoto</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/kenimo49/kernighan-fowler-torvalds-7-debugging-quotes-every-senior-still-repeats-4jog</link>
      <guid>https://dev.to/kenimo49/kernighan-fowler-torvalds-7-debugging-quotes-every-senior-still-repeats-4jog</guid>
      <description>&lt;p&gt;Some engineering lines refuse to fade.&lt;/p&gt;

&lt;p&gt;They survive because every senior ends up repeating them in code review, year after year, usually on a Thursday afternoon when a junior ships something that works and shouldn't.&lt;/p&gt;

&lt;p&gt;I spent the last few months decoding a hundred of these for a short Kindle book. Below are seven, from three names that keep surfacing when the topic is debugging: Brian Kernighan, Martin Fowler, Linus Torvalds. Each quote has its original public source. Each one has a modern code-review move I use when I quote it.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. "Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan &amp;amp; P. J. Plauger&lt;/strong&gt;, &lt;em&gt;The Elements of Programming Style&lt;/em&gt;, 2nd ed., ch. 2, 1978.&lt;/p&gt;

&lt;p&gt;Half a century later, this is the single question I ask when a PR shows up with a one-line regex, a nested ternary, or a &lt;code&gt;reduce&lt;/code&gt; that collapses six operations into one line. Not "is it correct." &lt;em&gt;How will you ever debug it?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI-pair-programming era rediscovers this one every quarter. Give Claude Code or Copilot a prompt like "make this shorter" and it will happily compress four lines into one with a currying trick. The code works. Six months later, nobody (human or model) can rewrite the same function from scratch, because the compressed form hides the steps.&lt;/p&gt;

&lt;p&gt;My review rule: if the author needs more than one sentence to explain what the line does, split it. I know, because I've been that author more than once.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. "The most effective debugging tool is still careful thought, coupled with judiciously placed print statements."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan&lt;/strong&gt;, &lt;em&gt;Unix for Beginners&lt;/em&gt;, 1979.&lt;/p&gt;

&lt;p&gt;I used to be embarrassed by &lt;code&gt;println&lt;/code&gt; debugging. Then I watched enough senior engineers reach for it before a debugger that I stopped apologizing.&lt;/p&gt;

&lt;p&gt;The quote still holds, with one update. The tool today is &lt;code&gt;console.log&lt;/code&gt;, &lt;code&gt;fmt.Println&lt;/code&gt;, &lt;code&gt;print()&lt;/code&gt;, or whatever your runtime offers, plus a tail of structured logs. Debuggers help when you can reproduce the bug and the state is static. Print statements help when the bug lives in a flow, in production, or at a boundary the debugger can't easily cross: gRPC, serverless cold start, worker queues, flaky async tests.&lt;/p&gt;

&lt;p&gt;The actual skill the quote names is &lt;strong&gt;careful thought&lt;/strong&gt;. The prints are only useful once you've guessed where to put them.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. "Controlling complexity is the essence of computer programming."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Brian Kernighan &amp;amp; P. J. Plauger&lt;/strong&gt;, &lt;em&gt;Software Tools&lt;/em&gt;, p. 319, 1976.&lt;/p&gt;

&lt;p&gt;A junior and a senior will both call code "complex." The junior usually means "I don't understand this yet." The senior usually means "nobody will ever understand this again, including the person who wrote it."&lt;/p&gt;

&lt;p&gt;I keep this quote pinned when reviewing refactors. The question isn't "is this clever." It's "does the next on-call engineer, at 2 a.m., with no context, have a chance?" Most production bugs I've chased started as a cleverness-driven refactor that added coupling the diff didn't name.&lt;/p&gt;

&lt;p&gt;Complexity accrues. Most PRs make it worse. The ones that make it better are rare and should be celebrated.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. "Any fool can write code that a computer can understand. Good programmers write code that humans can understand."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Martin Fowler&lt;/strong&gt;, &lt;em&gt;Refactoring: Improving the Design of Existing Code&lt;/em&gt;, p. 15, Addison-Wesley, 1999.&lt;/p&gt;

&lt;p&gt;This is the one line of Fowler's that engineers who've never read &lt;em&gt;Refactoring&lt;/em&gt; can still recite. Fair, because it compresses the whole book: tests exist so humans can change code; names exist so humans can read it; small methods exist so a human can hold one in their head.&lt;/p&gt;

&lt;p&gt;The habit I lifted from this: whenever I'm tempted to leave a comment explaining &lt;em&gt;what&lt;/em&gt; a block does, I rename or split instead. If the code reads without the comment, the comment was a smell.&lt;/p&gt;

&lt;p&gt;LLM code generators love long comments. Rename the function.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. "If it hurts, do it more often."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Martin Fowler&lt;/strong&gt;, &lt;a href="https://martinfowler.com/bliki/FrequencyReducesDifficulty.html" rel="noopener noreferrer"&gt;martinfowler.com, FrequencyReducesDifficulty&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Fowler surfaces this as one of his favorite soundbites. The surface reading sounds like masochism. The real reading: if deploys are painful, deploy daily; if merges are painful, merge every hour; if integration tests are painful, run them on every push.&lt;/p&gt;

&lt;p&gt;The pattern survives the current stack. Painful AI-code reviews? Review each PR as soon as it opens, not once a day in a batch. Painful dependency bumps? Automate them nightly instead of quarterly. Painful incident post-mortems? Hold them within 48 hours while people still remember.&lt;/p&gt;

&lt;p&gt;Every time I've watched a team avoid a painful activity, the pain compounded. Fowler's rule flips the compounding.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. "Talk is cheap. Show me the code."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Linus Torvalds&lt;/strong&gt;, &lt;a href="https://lkml.org/lkml/2000/8/25/132" rel="noopener noreferrer"&gt;Linux Kernel Mailing List, 25 Aug 2000&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Linus wrote this in the middle of a kernel discussion where someone was proposing changes without an implementation. The adage has outlived its original thread.&lt;/p&gt;

&lt;p&gt;Modern application: architecture debates on Slack where no participant has opened the file in question. Design reviews on a tool nobody has prototyped. "We should migrate to X" threads with no benchmark attached. The quote doesn't ban design. It bans debating before anyone has sampled the code. Open a branch. Commit a 30-line proof of concept. Attach the diff to the thread.&lt;/p&gt;

&lt;p&gt;I keep a text expander snippet with this quote. I pull it out maybe once a month, and every time it ends the thread in under an hour.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. "Bad programmers worry about the code. Good programmers worry about data structures and their relationships."
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Linus Torvalds&lt;/strong&gt;, Git mailing list, 2006. (&lt;a href="https://lwn.net/Articles/193245/" rel="noopener noreferrer"&gt;git mailing list via LWN, 2006-07-27&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The underrated debugging quote on this list. Most bugs that reach production aren't in the control flow. They're in the shape of the data: a nullable field someone assumed was present, a one-to-many relationship someone modeled one-to-one, a cache that lies about freshness, a queue that quietly drops ordering guarantees.&lt;/p&gt;

&lt;p&gt;When a bug surfaces, the first question I ask is what the data actually looks like right now, and what the code assumed it would look like. More than half the time, the fix is a schema change or a type refinement, and the logic was never wrong. I lost a weekend last year "fixing" logic that was fine — the schema was lying about a nullable column.&lt;/p&gt;

&lt;p&gt;Modern echo: in LLM pipelines, the hardest bugs don't live in the prompt. They live in the context layout the prompt silently depends on: what the retriever returned, in what order, with what metadata.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why these seven, out of a hundred
&lt;/h2&gt;

&lt;p&gt;The engineering world has more quotes than it has reviewed PRs. These seven survive translation across stacks: a 1978 line is still live in a 2026 Claude-Code-generated diff.&lt;/p&gt;

&lt;p&gt;If this slice landed, the full set (100 quotes across 10 chapters — code aesthetics, bugs and failure, design, teams, innovation, learning, craftsmanship, open source, the AI era, and an engineer's life) is in &lt;a href="https://kenimoto.dev/books/engineer-it-quotes?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=debug-7-quotes" rel="noopener noreferrer"&gt;100 Engineering Quotes Decoded&lt;/a&gt;. One decoded quote at a time, so you can read a decoding on a coffee break and bring it into code review the same day.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>career</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
