<?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: Chauncey Wang</title>
    <description>The latest articles on DEV Community by Chauncey Wang (@chncwang).</description>
    <link>https://dev.to/chncwang</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%2F4029378%2F71a792f4-5747-49a1-8ea6-518d3e31d5d7.png</url>
      <title>DEV Community: Chauncey Wang</title>
      <link>https://dev.to/chncwang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chncwang"/>
    <language>en</language>
    <item>
      <title>No neural nets, no tree: inside a Go engine that plays 40,000 games per second</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Fri, 21 Aug 2026 02:54:37 +0000</pubDate>
      <link>https://dev.to/chncwang/no-neural-nets-no-tree-inside-a-go-engine-that-plays-40000-games-per-second-1om5</link>
      <guid>https://dev.to/chncwang/no-neural-nets-no-tree-inside-a-go-engine-that-plays-40000-games-per-second-1om5</guid>
      <description>&lt;p&gt;In December 2012 — three years before AlphaGo — I wrote a Go AI in C++ called &lt;a href="https://github.com/chncwang/FoolGo" rel="noopener noreferrer"&gt;FoolGo&lt;/a&gt;. It has no neural networks, no opening book, no hand-crafted evaluation function. Just Monte Carlo tree search with UCB1, and enough systems engineering that it simulates &lt;strong&gt;about 40,000 complete games of Go per second&lt;/strong&gt; — benchmarked on my 2014 MacBook Air.&lt;/p&gt;

&lt;p&gt;It never got past beginner strength on 9×9, and I've always been upfront about that. Most of the repo's stars actually arrived after AlphaGo beat Lee Sedol in 2016: once the whole world wanted to know how Go AI worked, people came looking for the &lt;em&gt;pre-intuition&lt;/em&gt; machinery in readable form — what the field ran on before neural networks learned to feel a board. This post is a tour of the engineering that makes vanilla MCTS fast — because for vanilla MCTS, fast is the only thing there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why throughput is the whole game
&lt;/h2&gt;

&lt;p&gt;Pure MCTS has no way to evaluate a Go position directly. To judge a candidate move, it plays random games from that position all the way to the end, and counts wins. Strength is roughly a function of how many of these playouts you can afford per move. There's no clever prior to save you — the engine &lt;em&gt;is&lt;/em&gt; its simulation throughput. So every design decision below is about the same thing: making "play a full random game of Go" as close to free as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Board size fixed at compile time
&lt;/h2&gt;

&lt;p&gt;Everything in FoolGo is templated on the board length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BoardLen&lt;/span&gt; &lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FullBoard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 9×9 engine and a 19×19 engine are different types, compiled separately. That sounds like C++ showing off, but the payoff is concrete: every array in the hot path — the board, the chain structures, the hash tables of the hasher — has a size known at compile time. No &lt;code&gt;std::vector&lt;/code&gt; growth, no heap allocation during search, no pointer chasing where an array index will do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stones as disjoint sets, in flat arrays
&lt;/h2&gt;

&lt;p&gt;In Go, stones of the same color that touch form a &lt;em&gt;string&lt;/em&gt;, and the string — not the stone — is the unit of life and death: the moment a string's last liberty (adjacent empty point) is filled, the whole string is captured and removed from the board at once.&lt;/p&gt;

&lt;p&gt;Watch what a single move can do:&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%2Fy3m2ci7rp3ij38wpkwea.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%2Fy3m2ci7rp3ij38wpkwea.png" alt="Before/after: black connects two strings into one; liberty rings show the merged count" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before the move, black has two strings — and they are not equal. The pair at c4–c5 shares five liberties as one unit; the lone stone at e5 has been squeezed down to exactly one. In Go terms it is in atari: one White move from being eaten. Connecting at d5 is the rescue — and notice that this single placement fuses two strings and the new stone into one four-stone string with five liberties. Count the rings on the right: the arithmetic isn't additive. Merging liberty sets means deduplicating them — a detail that will matter shortly — and enemy stones don't count: White's four stones block four of what would otherwise be nine. The rescue cost White too: its d4–e4 string just lost one of its own liberties. Push any string's count to zero and it gets eaten — captured and removed from the board at once. A stone has four neighbors, so a single placement can fuse up to four strings (plus the new stone) into one.&lt;/p&gt;

&lt;p&gt;And that's the disjoint-set view of the same event — before, two sets; after, one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:   set A: head = c5,  stones: c4 → c5,  c5 → c5
                 liberties {b4, b5, c3, c6, d5}
          set B: head = e5,  stones: e5 → e5
                 liberties {d5}

play d5:  union(A, B, d5)

after:    set A: head = c5,  stones: c4 → c5,  c5 → c5,  d5 → c5,  e5 → c5
                 liberties {b4, b5, c3, c6, d6}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stone points straight at its set's head, so &lt;em&gt;find&lt;/em&gt; is a single array read. The price shows up at merge time: the absorbed side's stones get repointed to the surviving head — and &lt;code&gt;MergeLists&lt;/code&gt; chooses sides by size, relabeling the smaller list into the larger (the classic union-by-size trick that keeps total relabeling cheap). So the freshly played stone, a list of one, never wins the election: here d5 and then e5 are repointed into the c4–c5 pair's head. And the liberty sets OR together, minus the point just filled, plus the new stone's own empty neighbors. Hold that picture; the code below is nothing more than this, made fast.&lt;/p&gt;

&lt;p&gt;Now count what the engine must answer on &lt;em&gt;every&lt;/em&gt; move of every simulated game:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which string does this neighbor belong to?&lt;/strong&gt; — &lt;em&gt;find&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did my move drop that enemy string's liberties to zero?&lt;/strong&gt; If so, remove &lt;em&gt;all&lt;/em&gt; of its stones, immediately — an aggregate query plus member enumeration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is my move suicide?&lt;/strong&gt; — the same query, pointed at my own string&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did my move connect friendly strings?&lt;/strong&gt; Merge them — &lt;em&gt;union&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 40,000 games per second, a couple hundred moves per game, that's millions of move-executions per second, each running several of these queries. They all have to be near-O(1).&lt;/p&gt;

&lt;p&gt;This is the union-find problem wearing a Go costume — with two extra requirements the textbook version doesn't have: each set needs cheap &lt;strong&gt;member enumeration&lt;/strong&gt; (to delete a captured string from the board) and a cheap &lt;strong&gt;aggregate statistic&lt;/strong&gt; (the liberty count that decides capture and suicide). FoolGo's &lt;code&gt;ChainSet&lt;/code&gt; answers all of it with two flat arrays — one node per board point, one list record per potential chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Node&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;PositionIndex&lt;/span&gt; &lt;span class="n"&gt;next_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_head_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;nodes_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BoardLenSquare&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;PositionIndex&lt;/span&gt; &lt;span class="n"&gt;tail_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;len_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;BitSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;air_set_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;AirCount&lt;/span&gt; &lt;span class="n"&gt;air_count_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;lists_&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BoardLenSquare&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;list_head_&lt;/code&gt; is the &lt;em&gt;find&lt;/em&gt; pointer — every stone knows its string's representative. The &lt;code&gt;next_&lt;/code&gt; chain makes members enumerable without searching the board — that's the capture-removal path. And the per-list &lt;code&gt;air_set_&lt;/code&gt;/&lt;code&gt;air_count_&lt;/code&gt; are the aggregates that answer capture and suicide checks in constant time. Merging two strings is a list splice plus head-pointer updates — index arithmetic on preallocated arrays, no allocation, no tree balancing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Liberties as bitsets
&lt;/h2&gt;

&lt;p&gt;The expensive bookkeeping is the liberties themselves ("airs" in FoolGo's vocabulary): every placed stone changes the liberties of all its neighbors. FoolGo stores each string's liberties as a bitset over board points. When strings merge, their liberty sets merge with a bitwise OR; counting is a popcount. Some of the hottest bookkeeping in the engine compiles down to word-sized bit operations.&lt;/p&gt;

&lt;p&gt;Here is the connection at d5 again, seen through the bitsets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    b4 b5 c3 c6 d5 d6      (75 more bits, all 0)
pair {c4,c5}         1  1  1  1  1  0
lone {e5}            0  0  0  0  1  0
new  {d5}            0  0  0  0  0  1

OR                   1  1  1  1  1  1
clear d5 (filled)    1  1  1  1  0  1     popcount → 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three ORs, one bit-clear, one popcount. There is no deduplication logic anywhere: d5, a liberty of both black strings, is simply the same bit twice, and OR-ing makes the duplicate vanish by construction. On a 9×9 board the whole set fits in two machine words, so "merge three strings and recount the result's liberties" costs a handful of CPU instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a move
&lt;/h2&gt;

&lt;p&gt;All of that bookkeeping exists to make one operation cheap: actually playing a move. Here is FoolGo's real pipeline, from &lt;code&gt;full_board.h&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, is the move even legal?&lt;/strong&gt; &lt;code&gt;IsSuicide&lt;/code&gt; looks at the four neighbors of the empty point — and at nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for each of the 4 neighbors:
    empty?                                 → not suicide (instant liberty)
    friendly string with ≥ 2 liberties?    → not suicide (joins a string that still breathes)
    enemy string with exactly 1 liberty?   → not suicide (the move captures it, freeing space)
none of the above                          → suicide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four array reads. No board scan, no trial placement — every clause is answered by a per-string &lt;code&gt;air_count_&lt;/code&gt; that the merge machinery has been keeping current all along. The friendly clause needs ≥ 2 because the new stone fills one of that string's liberties on arrival; the enemy clause is the elegant one — a move onto your last-looking point is perfectly legal if it kills the surrounder first.&lt;/p&gt;

&lt;p&gt;Here are the clauses on the board:&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%2Fda4wics760fwaolwzewf.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%2Fda4wics760fwaolwzewf.png" alt="Three panels: a suicide point, the same point made legal by the kill clause, and the capture executed" width="800" height="234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the left position, e5 fails every clause: no empty neighbor, no friendly string to join, no enemy string at one liberty — suicide, illegal. Add a single black stone at d3 (middle) and the three-stone white string d4–d5–e4 falls to its last liberty: the &lt;em&gt;same point&lt;/em&gt; now passes the kill clause. Play it (right) and the pipeline below runs — the string is eaten first, &lt;code&gt;RemoveChain&lt;/code&gt; walking its cyclic list, so by the time the black stone lands it has two liberties: points it just vacated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then, play it.&lt;/strong&gt; &lt;code&gt;PlayBasicMove&lt;/code&gt; runs the same four-neighbor scan once more, now with consequences, in a very deliberate order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Captures first.&lt;/strong&gt; Any enemy neighbor string down to its last liberty is eaten on the spot: &lt;code&gt;RemoveChain&lt;/code&gt; walks its cyclic list and clears the stones — and the newly emptied point next door is immediately recorded as a liberty of the stone about to be placed. The new stone breathes into the space it just vacated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Place the stone&lt;/strong&gt;, and clear its point's bit from every adjacent string's liberty bitset — friend and enemy alike.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge.&lt;/strong&gt; &lt;code&gt;AddPiece&lt;/code&gt; creates the one-stone set and union-by-size folds it together with its friendly neighbors — the disjoint-set dance from earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suicide cleanup.&lt;/strong&gt; If, after all that, the just-built string has zero liberties, it is removed by the very same &lt;code&gt;RemoveChain&lt;/code&gt; — suicide isn't a special case, it's a capture whose victim is yourself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step is either a four-neighbor loop or an operation on the structures above — nothing touches the other 76 points of the board. That locality, times forty thousand games a second, is the entire performance story.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no tree
&lt;/h2&gt;

&lt;p&gt;The textbook picture of MCTS is a tree of nodes with parent/child pointers. FoolGo doesn't build one. Instead, every game state maps to a 64-bit Zobrist hash, and node statistics live in a flat hash table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;StaySelfHasher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="k"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;()(&lt;/span&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;hash_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hash_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the key is already a hash — don't hash a hash&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;unordered_map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NodeRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StaySelfHasher&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;node_record_map_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;StaySelfHasher&lt;/code&gt; is my favorite two lines in the repo: the key is a Zobrist hash, which is already uniformly distributed, so the map's hasher is the identity function.)&lt;/p&gt;

&lt;p&gt;This is a transposition table, and it quietly upgrades the search from a tree to a DAG: two different move orders reaching the same position share one node and one set of statistics, for free. Whenever different move orders transpose to the same hashed state, that's not a micro-optimization — it's extra effective simulations without simulating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zobrist hashing, incrementally
&lt;/h2&gt;

&lt;p&gt;Recomputing a position's hash from scratch would cost O(board area) per move. Zobrist hashing makes it O(changed stones): XOR out what left, XOR in what arrived.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;GetHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;FullBoard&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;BOARD_LEN&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// full&lt;/span&gt;
&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;GetHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HashKey&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;BoardDifference&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;chng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// incremental&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scheme is beautifully dumb. At startup, generate one random 64-bit number for every (point, state) pair — FoolGo's table is literally &lt;code&gt;board_hash_[81][3]&lt;/code&gt; — plus numbers for the side to move and for each possible ko point. A position's hash is the XOR of the numbers matching its current contents. Everything rests on one property: XOR is its own inverse, so XOR-ing the same number twice removes it. "Update" and "undo" are the same operation, and a move's hash cost is proportional to what the move changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d5&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;EMPTY&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;// d5 stops being empty...&lt;/span&gt;
       &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d5&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;// ...and becomes black&lt;/span&gt;
       &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BLACK&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;Z&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;WHITE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// turn marker: XOR out "Black to move", XOR in "White to move"&lt;/span&gt;
       &lt;span class="c1"&gt;// plus one pair of XORs per captured stone, if any&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The turn-marker pair is there because whose move it is &lt;em&gt;is part of the position&lt;/em&gt;. Look back at the diagram: with Black to move, e5 gets rescued; with White to move, e5 gets eaten — opposite fates, identical stones. If those two states hashed the same, the transposition table would merge them and their statistics would be garbage. So exactly one of two random "to move" numbers is always XOR-ed into the hash, and each move swaps them. Our connection at d5 thus touches four numbers; a capture would touch two more per removed stone. Nothing else on the board is looked at — which is what lets forty thousand games per second afford a fresh hash after every single move.&lt;/p&gt;

&lt;p&gt;The hasher also carries a table for the ko point (&lt;code&gt;ko_hash_&lt;/code&gt;, plus a no-ko number), and the reason is subtle. After a ko capture, one point is temporarily illegal to play. Two boards with identical stones but different ko status therefore have different legal moves — and if they hashed the same, the transposition table would happily merge them into one node, letting statistics gathered in one position answer questions about the other. Getting ko into the hash is the kind of detail that costs you an afternoon of debugging exactly once.&lt;/p&gt;

&lt;h2&gt;
  
  
  UCB1, verbatim
&lt;/h2&gt;

&lt;p&gt;The selection policy is the classic formula, and the code is honest about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nf"&gt;Ucb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;NodeRecord&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;node_record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;visited_count_sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetAverageProfit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;visited_count_sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;node_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetVisitedTime&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;Average observed value plus an optimism bonus that shrinks as a node gets visited: exploitation plus exploration in one line. Here it is with numbers — three candidate moves, a thousand playouts spent so far:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parent visits N = 1000          2·ln N ≈ 13.8

move   visits n   avg profit   bonus √(2·ln N / n)    UCB
A         700        0.52             0.14            0.66
B         250        0.46             0.24            0.70
C          50        0.38             0.53            0.91   ← next playout goes here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next simulation goes to C — the move with the &lt;em&gt;worst&lt;/em&gt; observed average. That's not a bug; the bonus is uncertainty made numeric. After only 50 samples, the search cannot yet distinguish a bad move from an unlucky one, so C's claim on the budget is still large. If C keeps disappointing, its average stays low while its bonus shrinks like √(ln N / n), and the playouts drift back to A, whose higher mean now stands on 700 samples. Spend where confidence is thinnest, harvest where confidence is strongest — the formula does both without a single special case. Everything else in the search exists to make evaluating it cheap, millions of times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multithreading by mutual avoidance
&lt;/h2&gt;

&lt;p&gt;FoolGo searches on multiple CPU threads sharing the one transposition table (a mutex guards it). The interesting choice is how threads avoid redundant work: rather than implementing virtual loss — the standard trick of temporarily penalizing a node while a thread explores it — FoolGo simply &lt;strong&gt;forbids a thread from descending into a node a peer is currently exploring&lt;/strong&gt;. Here's what that means at the node we just scored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;thread 1 arrives:  UCB says C (0.91) → descends into C, marks it in-progress
thread 2 arrives:  UCB says C — but C is taken → settles for B (0.70)
thread 3 arrives:  C and B both taken → gets pushed to A (0.66)
threads return:    marks cleared, statistics updated, selection sees fresh numbers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thread 2 wanted C — the policy's actual choice — and got the runner-up; thread 3 got third-best. That's the distortion: under contention, the search behaves as if the top candidates were briefly invisible. Virtual loss is the gentler version of the same idea — a temporary penalty instead of a wall, so a favorite whose lead is big enough can still absorb several threads at once. FoolGo's wall is cruder. But it's a few lines, it can't deadlock, and it was enough to scale a hobby engine across the cores of a laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the grown-ups solved the same problems
&lt;/h2&gt;

&lt;p&gt;FoolGo's choices get more interesting next to the famous open-source engines that came after it — &lt;a href="https://github.com/leela-zero/leela-zero" rel="noopener noreferrer"&gt;Leela Zero&lt;/a&gt; (2017, the community AlphaGo-Zero reproduction) and &lt;a href="https://github.com/lightvector/KataGo" rel="noopener noreferrer"&gt;KataGo&lt;/a&gt; (one of today's strongest open-source Go engines) — and the ones that came before it: &lt;a href="https://www.gnu.org/software/gnugo/" rel="noopener noreferrer"&gt;GNU Go&lt;/a&gt; (whose board code dates to the 1990s), &lt;a href="https://github.com/pasky/pachi" rel="noopener noreferrer"&gt;Pachi&lt;/a&gt;, and &lt;a href="https://github.com/lukaszlew/libego" rel="noopener noreferrer"&gt;libego&lt;/a&gt; (Łukasz Lew's minimalist MCTS library). I went digging through their codebases to compare notes on the same subproblems.&lt;/p&gt;

&lt;h3&gt;
  
  
  One skeleton, five engines
&lt;/h3&gt;

&lt;p&gt;Five engines, written independently across more than two decades, landed on the same core data structure — flat arrays over board points, a circular linked list threading each string's stones, one stone as the representative. The names barely differ: GNU Go (1990s) has &lt;code&gt;string_number[]&lt;/code&gt; and &lt;code&gt;next_stone[]&lt;/code&gt;, Pachi has &lt;code&gt;group_at[]&lt;/code&gt; and &lt;code&gt;groupnext_at[]&lt;/code&gt;, FoolGo has &lt;code&gt;list_head_&lt;/code&gt; and &lt;code&gt;next_&lt;/code&gt;, Leela Zero has &lt;code&gt;m_parent[]&lt;/code&gt; and &lt;code&gt;m_next[]&lt;/code&gt;, KataGo has &lt;code&gt;chain_head[]&lt;/code&gt; and &lt;code&gt;next_in_chain[]&lt;/code&gt;. There is no lineage here — the problem shape forces the answer — and GNU Go's comment from the 1990s could caption them all: "the stones in a string are linked together in a cyclic list." Five engines, two decades, one skeleton.&lt;/p&gt;

&lt;h3&gt;
  
  
  Six ways to count a breath
&lt;/h3&gt;

&lt;p&gt;The chain skeleton converged; liberty bookkeeping went six different ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GNU Go&lt;/strong&gt; keeps an exact count &lt;em&gt;plus&lt;/em&gt; the full list of liberty coordinates (&lt;code&gt;string_libs[].list[]&lt;/code&gt;) — a classical engine wants to reason about specific liberties, not just count them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pachi&lt;/strong&gt; caps the list at ten, refilled lazily — its own comment admits &lt;code&gt;libs&lt;/code&gt; "is only LOWER BOUND for the number of real liberties!!!" Playouts rarely need more than "is this 0, 1, or 2."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;libego&lt;/strong&gt; never dedupes at all: pseudo-liberties plus algebra. Its &lt;code&gt;Chain&lt;/code&gt; stores the count, sum, and sum of squares of its pseudo-liberty vertices — a chain is in atari exactly when count · Σx² = (Σx)², and the atari point is sum ÷ count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FoolGo&lt;/strong&gt;: the exact set as a bitset — merge is OR, dedup by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leela Zero&lt;/strong&gt;: exact scalar counts, paid for with a dedup walk at merge time (plus a pseudo-liberty helper for rough checks).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KataGo&lt;/strong&gt;: exact scalar counts, incremental, with bound-estimating shortcuts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same invariant, six answers — ranked, roughly, by how much each engine wants to &lt;em&gt;know about&lt;/em&gt; its liberties versus merely count them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three clauses, rediscovered
&lt;/h3&gt;

&lt;p&gt;Playing a move converged even harder than the data structures. FoolGo's three-clause suicide test — empty neighbor: no; friendly with two-plus liberties: no; enemy with exactly one: no — turns out to be structurally identical in Leela Zero's &lt;code&gt;is_suicide&lt;/code&gt; and KataGo's &lt;code&gt;isSuicide&lt;/code&gt;: the same three clauses, in the same order — independent rediscovery, because the test is simply the minimal correct answer the rules allow. There is no fourth clause to invent: a move survives by gaining a liberty directly, joining something that outlives it, or making room by killing. Leela Zero merely bolts a pseudo-liberty fast path onto the front (&lt;code&gt;if (count_pliberties(i)) return false;&lt;/code&gt; — any adjacent empty point settles it immediately). The real differences live at the edges. Pachi caches, for every point, how many of its neighbors are black, white, or off-board — &lt;code&gt;immediate_liberty_count&lt;/code&gt; is just 4 minus those counts — so the commonest clause is answered without visiting the neighbors at all. And GNU Go plays moves &lt;em&gt;reversibly&lt;/em&gt;: every board mutation is pushed onto a change stack (its comments report 20–30 entries per typical move) so the classical engine can read out a line and take it all back on a single board — whereas the playout engines, FoolGo included, never undo anything: they copy the board and let the copy die with the game.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wall, the tax, and no locks at all
&lt;/h3&gt;

&lt;p&gt;Parallel search reads as generations of one lineage — though not a chronological one. &lt;a href="https://fuego.sourceforge.net/" rel="noopener noreferrer"&gt;Fuego&lt;/a&gt;, the strong open MCTS engine of FoolGo's own era, had already gone further than everyone: a fully lock-free multithreaded tree search (Enzenberger &amp;amp; Müller, 2009) — three years &lt;em&gt;before&lt;/em&gt; FoolGo's global mutex. Its source rewards reading even now: each thread allocates nodes from its own pre-allocated array and links them to the parent only once fully initialized; if several threads expand the same node, the last writer wins and the others' work — including value updates already made — is knowingly thrown away. Correctness traded against ever waiting. Every node field is &lt;code&gt;volatile&lt;/code&gt;, virtual loss is already there (&lt;code&gt;m_virtualLossCount&lt;/code&gt;), and the in-code docs cite the exact chapters of the Intel manual whose memory-ordering guarantees the whole scheme leans on. The fool was not early; he was simple. Within the lineage of the simple: FoolGo's forbid-peers rule (2012) is the blunt ancestor, Leela Zero (2017) does it properly with textbook &lt;code&gt;virtual_loss()&lt;/code&gt; / &lt;code&gt;virtual_loss_undo()&lt;/code&gt; folded into node evaluations, and KataGo is the industrial endpoint — virtual loss as a &lt;em&gt;tunable&lt;/em&gt; (&lt;code&gt;numVirtualLossesPerThread&lt;/code&gt;, an atomic counter per node), over a sharded node table with a mutex pool and fully atomic stats structs.&lt;/p&gt;

&lt;h3&gt;
  
  
  From tree to graph
&lt;/h3&gt;

&lt;p&gt;The part that surprised me most. Leela Zero, for all its strength, uses a literal pointer tree and does &lt;em&gt;not&lt;/em&gt; merge transpositions; there's even a comment in &lt;code&gt;uct_select_child&lt;/code&gt; about counting parent visits manually "to avoid issues with transpositions." But in &lt;strong&gt;v1.11.0 (March 2022)&lt;/strong&gt;, KataGo shipped what its release notes call "a new stronger MCTS implementation that operates on a graph rather than a tree" — transposed move orders recombined into shared nodes, keyed by hash in a sharded node table. Which is, architecturally, what FoolGo's &lt;code&gt;unordered_map&amp;lt;HashKey, NodeRecord&amp;gt;&lt;/code&gt; was doing in 2012.&lt;/p&gt;

&lt;p&gt;Before I take a bow: KataGo's author also wrote a &lt;a href="https://github.com/lightvector/KataGo/blob/master/docs/GraphSearch.md" rel="noopener noreferrer"&gt;first-principles document&lt;/a&gt; explaining that naively applying tree-MCTS statistics to a DAG — exactly what FoolGo does — is &lt;em&gt;unsound&lt;/em&gt;: shared nodes break the running-statistics formulation in subtle ways, and doing it correctly (plus handling ko and superko) is the actual hard part. So no, my hobby engine did not do graph search before KataGo. It wandered into the right building ten years early, without knowing why the floor needed reinforcing. KataGo's contribution was the reinforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it adds up to — and where it stops
&lt;/h2&gt;

&lt;p&gt;Flat arrays, bitwise liberty tracking, incremental hashing, a pointer-free "tree," and threads that stay out of each other's way: 40,000 games per second on a laptop from 2014.&lt;/p&gt;

&lt;p&gt;And yet: beginner strength. That plateau is the honest lesson of the repo. Uniformly random playouts are a terrible evaluation function, and no amount of throughput fixes their bias — stronger engines of that era spent their effort on playout &lt;em&gt;policy&lt;/em&gt;, and then 2016 arrived and neural networks replaced blind rollouts with intuition. AlphaGo kept the tree search; it swapped out exactly the part FoolGo had made fast.&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/chncwang/FoolGo" rel="noopener noreferrer"&gt;github.com/chncwang/FoolGo&lt;/a&gt; — readable on purpose, PRs welcome, and still, I'd argue, one of the clearer ways to see what game-tree search looks like with the covers off.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;These days I build &lt;a href="https://clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt;, an AI-powered clinical-trial matcher, and write about building it at &lt;a href="https://chncwang.substack.com" rel="noopener noreferrer"&gt;chncwang.substack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>ai</category>
      <category>algorithms</category>
      <category>performance</category>
    </item>
    <item>
      <title>Claude Code can make videos: it records the app, narrates with ElevenLabs, and syncs audio to video automatically</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Sat, 15 Aug 2026 03:40:08 +0000</pubDate>
      <link>https://dev.to/chncwang/claude-code-can-make-videos-it-records-the-app-narrates-with-elevenlabs-and-syncs-audio-to-video-7g8</link>
      <guid>https://dev.to/chncwang/claude-code-can-make-videos-it-records-the-app-narrates-with-elevenlabs-and-syncs-audio-to-video-7g8</guid>
      <description>&lt;p&gt;I'm a solo builder. I needed a 2-minute product demo for &lt;a href="https://clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt; — a free tool I built that matches cancer patients to clinical trials. I can fumble through OBS and iMovie, but I'm not proficient — and Claude Code does it faster.&lt;/p&gt;

&lt;p&gt;So I asked &lt;strong&gt;Claude Code&lt;/strong&gt; — an agentic coding tool — to make it. And it did: a narrated walkthrough where the voiceover lands exactly on the on-screen action. I never opened a screen recorder. I never opened a video editor. I never manually lined up a single caption to a single frame.&lt;/p&gt;

&lt;p&gt;Here's &lt;a href="https://www.youtube.com/watch?v=gbVJLpa22Io" rel="noopener noreferrer"&gt;the video it produced&lt;/a&gt;. This post is about the three things the agent did to make it — because I think that combination is new.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. It recorded the app — no screen recording
&lt;/h2&gt;

&lt;p&gt;Instead of me screen-capturing a session by hand, the agent wrote a Playwright script that drives the &lt;strong&gt;real, live web app&lt;/strong&gt;: it opens the site, fills out the 10-step patient wizard with a synthetic case, submits, and records the finished results page — all headless, straight to video.&lt;/p&gt;

&lt;p&gt;That means no manual take, no re-shooting when I fumble a click, no "oops the mouse jittered." The recording is &lt;strong&gt;code&lt;/strong&gt;, so it's deterministic and repeatable. When the product changes, the agent re-runs the script and out comes a fresh clip. It even injected a fake cursor that glides between elements, because a headless recording has no real mouse pointer.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. It generated the narration — no microphone
&lt;/h2&gt;

&lt;p&gt;I didn't record a voiceover. The agent wrote the narration script, then called the &lt;strong&gt;ElevenLabs&lt;/strong&gt; text-to-speech API to synthesize it in a clean, consistent voice. If I want to change a line, it edits the text and regenerates that clip in seconds — no re-recording, no "let me find a quiet room," no matching my tone across takes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the agent calls ElevenLabs per narration phrase&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.elevenlabs.io/v1/text-to-speech/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VOICE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xi-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eleven_multilingual_v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. It aligned audio to video — no timeline editor
&lt;/h2&gt;

&lt;p&gt;This is the part that normally needs a human in a video editor, dragging clips around a timeline until the words match the picture. The agent did it &lt;strong&gt;automatically&lt;/strong&gt;, and this is the genuinely clever bit:&lt;/p&gt;

&lt;p&gt;While recording, it logs the timestamp of every key on-screen moment — the submit click, the results appearing, a trial opening, the "copy to AI" dropdown.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;beat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`BEAT &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; @ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// BEAT submit-click   @ 46.83s&lt;/span&gt;
&lt;span class="c1"&gt;// BEAT results-shown  @ 68.73s&lt;/span&gt;
&lt;span class="c1"&gt;// BEAT trial-open     @ 90.17s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it cuts the narration into phrases, one per beat, and places each phrase at its beat's timestamp in the final mix (&lt;code&gt;ffmpeg&lt;/code&gt;'s &lt;code&gt;adelay&lt;/code&gt;). The result: when the voice says "now it goes to work," the button is being clicked; when it says "open any trial," the trial is opening. &lt;strong&gt;The sync falls out of the recording itself&lt;/strong&gt; — no dragging, no eyeballing, no manual alignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Making a product demo used to mean: screen-record a take, write a script, record a voiceover, then sit in an editor syncing them. Four manual steps, each needing a skill (or a person).&lt;/p&gt;

&lt;p&gt;Here it was &lt;strong&gt;one conversation with an agent&lt;/strong&gt;. The whole pipeline is code — record → narrate → align → assemble — so it's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repeatable&lt;/strong&gt;: product changes? Re-run. Fresh, re-synced video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic&lt;/strong&gt;: same framing and pacing every time, no shaky live take.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster than me&lt;/strong&gt;: I can muddle through OBS and iMovie, but slowly and not well — the agent does it faster, and I don't have to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent also quietly handled the fiddly parts I'd never want to — a site that won't render headless (screenshot + Ken Burns instead), audio mixing that silently halves volume, a blank loading frame that throws off the timing. I didn't debug any of it; it did.&lt;/p&gt;

&lt;p&gt;The shift, for me, is that &lt;strong&gt;making a demo video is now something you ask for, not something you produce.&lt;/strong&gt; The example here is my own project — &lt;a href="https://clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt;, a free clinical-trial matching tool for cancer patients — but nothing about the approach is specific to it. If you have a web app and an agent, you can have a narrated, synced demo without touching a recorder or an editor.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>playwright</category>
      <category>ffmpeg</category>
    </item>
    <item>
      <title>Building a One-Person Software Shop with Claude Code</title>
      <dc:creator>Chauncey Wang</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:30:07 +0000</pubDate>
      <link>https://dev.to/chncwang/building-a-one-person-software-shop-with-claude-code-1d6i</link>
      <guid>https://dev.to/chncwang/building-a-one-person-software-shop-with-claude-code-1d6i</guid>
      <description>&lt;p&gt;I build a clinical-trial matching product alone — no cofounder, no team. Just one person, an AI coding agent, and a set of conventions that keep "vibes-based solo dev" from falling apart across weeks of parallel work.&lt;/p&gt;

&lt;p&gt;The first version was a command-line tool, &lt;a href="https://github.com/chncwang/ClinTrialFinder" rel="noopener noreferrer"&gt;open-sourced&lt;/a&gt;. Turning it into a &lt;em&gt;real web app&lt;/em&gt; — something a patient could open in a browser and trust with their situation — is the jump where a lot of solo projects quietly stall. I made it: today it's a live web app with ~30 drug pages, dozens of disease-specific trial landscapes, and a matcher real cancer patients use to find trials.&lt;/p&gt;

&lt;p&gt;I'm also a cancer patient; I built this partly because I needed it to exist. So I care less about it being impressive than &lt;em&gt;correct&lt;/em&gt; — a wrong trial match wastes a sick person's time. That constraint is the whole point: &lt;strong&gt;the interesting part isn't that an AI writes my code — it's the scaffolding that makes its work trustworthy when I'm not watching every keystroke.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A note on the tool first, then the transferable patterns — most of them scar tissue from something that broke once. Everything's sanitized — fake IPs, generic paths, invented tasks; the real secrets stay home. And the fake IPs aren't only for the article: the real task files never held a raw IP or email, and the server logs that hold IP addresses are purged within 14 days — matching the privacy policy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Claude Code, specifically
&lt;/h2&gt;

&lt;p&gt;People ask why Claude Code and not one of the other coding agents. Two reasons — one soft, one hard.&lt;/p&gt;

&lt;p&gt;The soft one: it infers intent from less. I can hand it a terse, half-specified ask — "the ranking's off for first-line patients, dig into it" — and it usually fills the gaps the way I &lt;em&gt;meant&lt;/em&gt;, not the way I literally typed. That matters when I'm running several sessions and can't write a full spec for each. Subjective, not a benchmark — but it's what keeps me reaching for it.&lt;/p&gt;

&lt;p&gt;The hard one: my review workflow feeds real patient-submission data to the agent — I pull a patient's result set to check whether the matcher did right by them. ClinTrialFinder's privacy policy names Anthropic's Claude as a tool for exactly that — "quality-checking match results, diagnosing issues." Using Claude Code for the review keeps the implementation matched to what patients were told. Other disclosed vendors handle other steps; the point isn't that Claude is the only option — it's that the words I show patients and the tools I run stay in lockstep.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core bet: your task tracker is a git repo
&lt;/h2&gt;

&lt;p&gt;Most people reach for Jira, Linear, Notion. I keep every task as a plain file in a git repo of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project-manager/            # a git repo
  tasks/
    412-fix-ranking-edge-case.html
    413-wizard-validation.html
    414-review-user-submission.html
&lt;/code&gt;&lt;/pre&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%2F308yvd2s2kwc7srj5n0x.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%2F308yvd2s2kwc7srj5n0x.png" alt="One task file, rendered — fixed shape, plain HTML in git. (Sanitized example.)" width="800" height="1152"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One file per task, fixed shape — priority, status, the problem, the fix, verification plan, links to related tasks. No API, no board, no login. Just files in git — 500+ of them now, and the flat directory has never needed anything fancier.&lt;/p&gt;

&lt;p&gt;Why this beats a tracker &lt;em&gt;for an AI-assisted solo shop specifically&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version-controlled.&lt;/strong&gt; The backlog is plain files in git — every status flip and edit is a diff you can read, branch, or revert, exactly like source. It's a history you own and grep, not rows in someone else's database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queryable with the tools the agent already has.&lt;/strong&gt; "Next free task number?" is &lt;code&gt;ls | sort&lt;/code&gt;. "Which tasks touch the ranking bug?" is &lt;code&gt;grep -rl&lt;/code&gt;. No integration — just &lt;code&gt;grep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linkable and self-documenting.&lt;/strong&gt; Tasks cross-link; six weeks later the &lt;em&gt;why&lt;/em&gt; is one click away, written at the time, not reconstructed from memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The insight: &lt;strong&gt;when your teammate is an AI agent, your project management should be a git repo of plain files it can grep — not a SaaS it has to poke through an API.&lt;/strong&gt; (I use HTML for the files — renders and links nicely — but the format is the least interesting part; Markdown would do.)&lt;/p&gt;

&lt;p&gt;One file sits on top of that flat directory: a single &lt;strong&gt;overview&lt;/strong&gt; — a hand-maintained index that splits the backlog into &lt;em&gt;the full list&lt;/em&gt; and &lt;em&gt;the sprint&lt;/em&gt; (what to actually work on now). The task files are the atomic units; the overview is the priority view.&lt;/p&gt;

&lt;p&gt;And two kinds of judgment get their own &lt;em&gt;role&lt;/em&gt;. Worker sessions append tasks freely as they surface — a bug found mid-fix, a spinoff from a review — but none of them promotes itself into the sprint. That call belongs to a dedicated &lt;strong&gt;"CEO" session&lt;/strong&gt;: a Claude Code session that reads the backlog, decides what's worth doing now (via its own &lt;code&gt;update-active-sprint&lt;/code&gt; skill), &lt;em&gt;and&lt;/em&gt; proposes the forward-looking work the workers won't file on their own — new features, UI refinements, the product's next move. Reactive filing is mechanical and belongs to whoever hit the problem; setting direction — both prioritizing and proposing — is judgment, so I concentrate it in one named role instead of smearing it across four sessions that each think &lt;em&gt;their&lt;/em&gt; task is the important one.&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%2Fm3xf646kslp0yv9nkwtu.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%2Fm3xf646kslp0yv9nkwtu.png" alt="The overview — the CEO-promoted Active Sprint on top, the full backlog below. (Sanitized example.)" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Parallelism — four sessions, each a standing role
&lt;/h2&gt;

&lt;p&gt;Here's where it gets honest. I often run &lt;strong&gt;four Claude Code sessions at once&lt;/strong&gt;, against a shared backlog and shared repos — but they're not four workers chewing through the same queue. Each holds a standing &lt;strong&gt;role&lt;/strong&gt;: a &lt;strong&gt;CEO&lt;/strong&gt; that prioritizes and proposes new work, an &lt;strong&gt;SEO/content&lt;/strong&gt; session that also watches the traffic and files a review task whenever a patient submits, and &lt;strong&gt;two full-stack engineers&lt;/strong&gt; working different tasks in parallel. And &lt;em&gt;full-stack&lt;/em&gt; undersells it — one engineer session will write HTML and CSS, write the Python behind it, review a real patient submission and file the fix tasks its defects reveal, and rewrite a matching prompt, all in one afternoon; a single context spans what used to be four specialties.&lt;/p&gt;

&lt;p&gt;That engineer session also writes as it works — logging its findings and progress back onto the task file, so the task becomes a running record of what was tried and learned, not a write-once spec. That record earns its keep: when a mid-work diagnostic disproves the task's own premise — the "bug" was correct behavior, the evidence was confounded — the CEO reads the update and &lt;strong&gt;demotes the task back to the backlog&lt;/strong&gt; instead of shipping it. The sprint self-corrects.&lt;/p&gt;

&lt;p&gt;And these sessions don't close when a task is done — each stays open and picks up the next thing in its lane, so it accumulates context instead of starting cold each time. A big multiplier for one person — the nearest thing to a team I've got.&lt;/p&gt;

&lt;p&gt;They all run from the same home directory — one shared filesystem, not four sandboxes. That's mostly the point: the SEO session opens the analytics export I just downloaded to &lt;code&gt;~/Downloads&lt;/code&gt;, any session can reach any repo in the tree, and nobody has to shuttle files between isolated boxes.&lt;/p&gt;

&lt;p&gt;The one place that sharing bites is the two engineers — point both at the same working tree and they'll clobber each other, one's half-finished edit sitting there when the other runs its tests. So each works in its own &lt;strong&gt;git worktree&lt;/strong&gt;: a separate checkout on its own branch, sharing the repo's history but not its uncommitted state. Shared filesystem, isolated working copies — both build, commit, and test at once without ever seeing each other's in-progress files, and each branch merges to main only after I've reviewed it.&lt;/p&gt;

&lt;p&gt;Those roles don't act in a vacuum — here's the full lifecycle they move a task through, for both kinds of task the shop runs.&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%2Fdxndp5k4t4alcccwneo3.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%2Fdxndp5k4t4alcccwneo3.png" alt="The life of a task — two task types sharing one backlog, color-coded by which role acts. Dev lane (top): Filed → Backlog → Active Sprint → In progress → Review gate → Shipped, with a Diagnostics box off In progress (premise holds → keep building; premise disproved → the CEO demotes the same task back to the Backlog) and a Review-gate send-back to In progress for rework. Review lane (bottom): a patient submission triggers a review task (filed by the SEO session, audited by an engineer across the three result pools, funnel, and retrieval); a clean audit closes, but a defect found in review spawns a new fix task up into the Backlog. (Sanitized example.)" width="800" height="423"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Of those 500-plus task files, about &lt;strong&gt;90 were spawned by reviewing real patient submissions&lt;/strong&gt; — the rest are features and fixes the CEO session or I dreamed up. That ratio is the part I care about: every genuine submission gets audited, and the ones that expose a gap become fix tasks. Roughly &lt;strong&gt;230 of the whole are done or shipped&lt;/strong&gt;; the rest are a living backlog. The traffic isn't huge — a few hundred submissions over the tool's life, at least &lt;strong&gt;100 real enough to enter an email to get their results back&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Every change gets its own URL before it's real
&lt;/h2&gt;

&lt;p&gt;The counterpart to building in parallel: every change to the product needs somewhere to &lt;em&gt;run&lt;/em&gt; that isn't production and isn't the other tasks in flight. So every task that touches the product gets its own &lt;strong&gt;isolated instance&lt;/strong&gt; — a fresh clone of the repo, its own service on its own port, reachable at its own private review URL behind a reverse proxy.&lt;/p&gt;

&lt;p&gt;Under the hood it's one small nginx config: a location block per instance, each routing a private sub-path to that instance's local port (&lt;code&gt;/task-&amp;lt;name&amp;gt;/&lt;/code&gt; → a service on &lt;code&gt;127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;). Spinning one up is a clone, a service on a fresh port, and a few lines of proxy. The honest ceiling is &lt;strong&gt;memory&lt;/strong&gt; — each instance is a full running copy of the app, so the dev box has to hold several at once; that, more than anything, caps how many tasks I can keep live for review at the same time.&lt;/p&gt;

&lt;p&gt;At any moment I might have several live, independently-viewable copies of the product up — one per task — each showing exactly that task's changes and nothing else. I open the URL, click through the actual rendered thing, and see the change in situ before it's anywhere near a user.&lt;/p&gt;

&lt;p&gt;That's the deploy gate: &lt;strong&gt;nothing reaches production until I've looked at it running on its own instance.&lt;/strong&gt; Build on an isolated branch → spin up an instance → review the real rendered page at its URL → approve → merge → deploy. The agent never pushes to prod on its own — the "approve" is mine, and it's a look at a &lt;em&gt;running thing&lt;/em&gt;, not a diff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transferable pattern:&lt;/strong&gt; give every unit of parallel work its own running, reviewable instance. "Does it actually work, rendered, in isolation?" is a question you can only answer if the work has somewhere to live that isn't production and isn't your other tasks. The isolation is what makes parallel &lt;em&gt;and&lt;/em&gt; careful compatible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Skills that encode judgment, not macros
&lt;/h2&gt;

&lt;p&gt;Claude Code lets you define &lt;strong&gt;skills&lt;/strong&gt; — named routines the agent runs on command. The naive use is automation: "deploy the site," "run the tests." Useful, but shallow.&lt;/p&gt;

&lt;p&gt;The valuable skills encode &lt;em&gt;how a domain expert thinks&lt;/em&gt; — the judgment, not just the steps.&lt;/p&gt;

&lt;p&gt;My highest-value one reviews a user submission. It doesn't just dump data. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pulls the result across three pools (shown to the user / computed-but-hidden / rejected).&lt;/li&gt;
&lt;li&gt;Reconstructs what the user actually &lt;em&gt;did&lt;/em&gt; from the logs — did they wait, did they click through, did they leave.&lt;/li&gt;
&lt;li&gt;Checks whether a genuinely-good result got silently dropped &lt;em&gt;before&lt;/em&gt; the scoring stage even saw it (a whole class of bug that's invisible if you only look at what was shown).&lt;/li&gt;
&lt;li&gt;Cross-references anything it finds against a catalog of known past defects.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's not a macro. That's a reviewer's &lt;em&gt;worldview&lt;/em&gt; — what to suspect, what to verify, what caveat to attach to a claim — written down once and rerun consistently. When I invoke it, I'm not saving keystrokes; I'm borrowing a disciplined second brain that never gets lazy on step 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transferable pattern:&lt;/strong&gt; your best skills should capture the &lt;em&gt;reasoning&lt;/em&gt; of your most careful self, especially the checks you'd skip when you're tired. Automation saves time. Encoded judgment saves you from your own shortcuts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The operating manual: where corrections become rules
&lt;/h2&gt;

&lt;p&gt;There's a file Claude Code reads at the start of every session — CLAUDE.md. Mine has grown into an operating manual: the project's standing rules, accumulated one mistake at a time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deployment discipline.&lt;/strong&gt; &lt;em&gt;Never deploy to production without explicit sign-off. Never edit files directly on the server — always local, commit, push, pull.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected behaviors.&lt;/strong&gt; How to format a task, when to sync which repo, what "done" means.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those started as a bug. The first time the agent restarted prod and interrupted a live request, the fix wasn't "don't do that this time" — it was a line in the manual so it never happens again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's the load-bearing pattern:&lt;/strong&gt; a correction that lives only in a chat window evaporates; a correction written to a file that loads next session is a permanent behavior change. The manual is the accumulated scar tissue of the project — the difference between an agent that repeats your mistakes and one that compounds your lessons. The test of a good rule is simple: can the mistake it came from happen again?&lt;/p&gt;




&lt;h2&gt;
  
  
  What actually transfers
&lt;/h2&gt;

&lt;p&gt;Strip away my specifics and here's what I'd hand another solo builder working with an AI agent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make your project management out of primitives your agent is fluent in.&lt;/strong&gt; Files, grep, git — not a SaaS behind an API. The backlog should be as greppable as the code. Let worker sessions append to it freely, but concentrate direction-setting — both prioritizing and proposing new work — into &lt;em&gt;one&lt;/em&gt; role, a dedicated "CEO" session, instead of every session promoting its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run parallel sessions as standing roles, not a shared queue.&lt;/strong&gt; Give each a lane and let them share one filesystem so they see the same world — then isolate what would collide (a git worktree per engineer) so "more hands" never becomes clobbered work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give every change somewhere to run before production.&lt;/strong&gt; Isolated per-task instances behind a reverse proxy let you review the real rendered product — so "approve" is a look at a running thing, not a diff, and prod stays a deliberate step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write skills that encode judgment, not just steps.&lt;/strong&gt; Capture the careful reasoning you'd skip when tired. That's the compounding asset; automation is just the floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep an operating manual, and treat every correction as a candidate rule&lt;/strong&gt; — written to a file that loads next session, not left in a chat window that evaporates. The manual is your project's scar tissue; the test of a rule is whether the mistake it came from can happen again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is about the AI being the smartest. It's about building the scaffolding that makes an AI agent's work &lt;em&gt;trustworthy&lt;/em&gt; — which, when the output affects a sick person looking for a trial, is the only thing that matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://www.clintrialfinder.info" rel="noopener noreferrer"&gt;ClinTrialFinder&lt;/a&gt; solo, with Claude Code, as a patient myself. If any of these patterns are useful in your own shop, I'd genuinely like to hear how they hold up — the failure modes especially.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>aiagents</category>
    </item>
  </channel>
</rss>
