<?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: JASHANPREET KAUR</title>
    <description>The latest articles on DEV Community by JASHANPREET KAUR (@jashanpreet_kaur_917e774f).</description>
    <link>https://dev.to/jashanpreet_kaur_917e774f</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%2F4147825%2Fcaad94be-a772-409f-ad22-bafc06c88378.png</url>
      <title>DEV Community: JASHANPREET KAUR</title>
      <link>https://dev.to/jashanpreet_kaur_917e774f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jashanpreet_kaur_917e774f"/>
    <language>en</language>
    <item>
      <title>Nikal: A local AI agent that remembers your comfort—and tells you to go outside</title>
      <dc:creator>JASHANPREET KAUR</dc:creator>
      <pubDate>Sat, 10 Oct 2026 19:23:46 +0000</pubDate>
      <link>https://dev.to/jashanpreet_kaur_917e774f/nikal-in-gwalior-go-outside-needs-a-time-attached-4gpf</link>
      <guid>https://dev.to/jashanpreet_kaur_917e774f/nikal-in-gwalior-go-outside-needs-a-time-attached-4gpf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In Gwalior, “go outside” needs a time attached.&lt;/p&gt;

&lt;p&gt;A forecast retrieved on 11 October at 3:17 PM IST shows why. At 4 PM it gave 32.6°C and a forecast US AQI of 108. At 5 PM it gave 31.5°C and an AQI of 120. The afternoon cooled as the air got worse.&lt;/p&gt;

&lt;p&gt;With limits of 32°C and AQI 100, neither hour passed. Nikal's card for that run said: “Nothing suitable left today. Tomorrow 7:00 AM IST (12 Oct) looks best.” That is a forecast-based suggestion, not a confirmed outing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Nikal means “go out” in Hindi. It is a small TypeScript CLI that checks Gwalior's hourly heat, air quality, rain, UV and daylight against comfort limits I have saved. A local Qwen 2.5 7B model, running through Ollama, then picks one hour and one mapped walking place from what passed, and an optional SerpApi search looks for outdoor events. It prints one card and exits; if nothing passes today it checks tomorrow, and if neither day qualifies it says so without relaxing the limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fjashanpreet-k%2FNikal%2Fmain%2Fdocs%2Fimages%2Fnikal-demo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fjashanpreet-k%2FNikal%2Fmain%2Fdocs%2Fimages%2Fnikal-demo.gif" alt="Terminal recording of five actual Nikal CLI runs: synthetic fixture inference and memory, followed by an unkeyed live run" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Recorded on 11 October 2026 at 3:43 PM IST with &lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;VHS&lt;/a&gt;. Inference waits were shortened; outputs come from actual CLI runs. Frames are hidden during model and network waits; the output is unedited. &lt;a href="https://youtu.be/Up5b_2EGtwo" rel="noopener noreferrer"&gt;Watch the same recording as a video on YouTube&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Persistent memory: 5 PM to 7 AM
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Synthetic forecast and places, real local Qwen inference.&lt;/strong&gt; Each command below runs in a separate CLI process against the same isolated demo profile. The cards label the fixture &lt;code&gt;OFFLINE FIXTURE · synthetic data&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Recorded result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;nikal --fixture&lt;/code&gt; with defaults&lt;/td&gt;
&lt;td&gt;Qwen selects &lt;strong&gt;5 PM&lt;/strong&gt;, the hour with the lowest forecast AQI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal chat "I dislike heat above 30°C and prefer mornings."&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persists &lt;code&gt;maxTemp: 32 → 30&lt;/code&gt; and &lt;code&gt;period: "any" → "morning"&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal prefs show&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A new process retrieves &lt;strong&gt;30°C + morning&lt;/strong&gt; from Mastra working memory.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;nikal --fixture&lt;/code&gt; again&lt;/td&gt;
&lt;td&gt;Same synthetic facts, new process: Qwen now selects &lt;strong&gt;7 AM&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The forecast did not change between the first command and the last. Only what Nikal remembered did.&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%2Fkdh11zvte9djvpzkuqzq.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%2Fkdh11zvte9djvpzkuqzq.png" alt="Terminal frame from the recording: chat saves preferences in the isolated demo profile and a new process reads them back" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;An actual terminal frame from the recording. The fixture facts are synthetic; the model inference, tool call and SQLite persistence are real.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The separate live segment
&lt;/h3&gt;

&lt;p&gt;The recording ends with one live run from approximate Gwalior city-centre coordinates, with live Open-Meteo conditions and live Overpass places. It recommends &lt;strong&gt;12 October, 7 AM IST: 25°C and forecast US AQI 93&lt;/strong&gt;.&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%2F8qbcudrk0c3aj0xdgqqk.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%2F8qbcudrk0c3aj0xdgqqk.png" alt="Terminal frame from the unkeyed live segment: live weather, AQI and Overpass places; SerpApi search skipped" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This segment ran without a SerpApi key.&lt;/strong&gt; It shows no search results. The keyed evidence further down comes from a different, earlier run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outdoor trial
&lt;/h3&gt;

&lt;p&gt;I have not completed an outdoor trial yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/jashanpreet-k/Nikal" rel="noopener noreferrer"&gt;GitHub: jashanpreet-k/Nikal&lt;/a&gt;, MIT-licensed.&lt;/p&gt;

&lt;p&gt;The latest &lt;code&gt;npm run check&lt;/code&gt;, on 11 October at 6:50 PM IST, passed type-checking, the build and 122 tests. The tests use mocked or synthetic model and API responses. The real runs are kept apart in the &lt;a href="https://github.com/jashanpreet-k/Nikal/blob/main/docs/VERIFICATION.md" rel="noopener noreferrer"&gt;verification report&lt;/a&gt;, the &lt;a href="https://github.com/jashanpreet-k/Nikal/blob/main/docs/EVIDENCE_EXCERPT.md" rel="noopener noreferrer"&gt;curated evidence excerpt&lt;/a&gt; and the recording source &lt;a href="https://github.com/jashanpreet-k/Nikal/blob/main/scripts/demo.tape" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/demo.tape&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Built on 10–11 October 2026. Solo: &lt;a class="mentioned-user" href="https://dev.to/jashanpreet_kaur_917e774f"&gt;@jashanpreet_kaur_917e774f&lt;/a&gt;, with AI coding assistance; no human teammates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;

&lt;p&gt;You need Node 22.13 or newer. The four commands below need nothing else: no Ollama, no API key, and no network once the install has finished.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/jashanpreet-k/Nikal.git
&lt;span class="nb"&gt;cd &lt;/span&gt;Nikal
npm ci &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;link
&lt;/span&gt;nikal &lt;span class="nt"&gt;--fixture&lt;/span&gt; &lt;span class="nt"&gt;--no-model&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints a card from an invented forecast with the model switched off. For the real thing, install &lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;, run &lt;code&gt;ollama pull qwen2.5:7b&lt;/code&gt;, and then:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type this&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal doctor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checks that the local model answers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal --fixture&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The invented forecast again, with local Qwen choosing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The live card: Gwalior's forecast and mapped places.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal chat "I dislike heat above 30°C and prefer mornings."&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Saves preferences from plain words.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal prefs set --radius 1.5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only suggests places within 1.5 km.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal prefs show&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shows what it remembers. &lt;code&gt;nikal prefs reset&lt;/code&gt; restores the defaults.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nikal --help&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lists every command and limit.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tested on macOS with Node 24; other systems are untested. Preferences are stored on your own machine, in &lt;code&gt;~/.nikal&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Mastra does here
&lt;/h3&gt;

&lt;p&gt;I wanted a small local model to make the choice without being trusted with any fact.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools.&lt;/strong&gt; Weather, places and events are each a &lt;code&gt;createTool&lt;/code&gt; with Zod input and output schemas, so a malformed API response is rejected before it reaches the card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel workflow with a join.&lt;/strong&gt; &lt;code&gt;fetchConditions → [findPlaces ‖ findEvents] → joinRetrieval → agentDecides → formatCard&lt;/code&gt;. Retrieval always runs in code. Places and events fail independently: in recorded takes where every map server failed, the card still gave a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local agent.&lt;/strong&gt; Qwen sees only the hours and places that passed my limits and returns IDs and a reason code. Nikal validates them, allows one repair, then labels a deterministic fallback. Numbers and links on the card come from retrieved data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent memory.&lt;/strong&gt; Preferences live in resource-scoped Mastra working memory on LibSQL, a local SQLite file. The first workflow step reads them before anything is fetched, so a saved limit changes which hours reach the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staged preference writes.&lt;/strong&gt; The chat tool only stages a validated patch. Nikal commits it after generation succeeds, then prints the persisted old and new values, not the model's reply.&lt;/li&gt;
&lt;/ul&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%2Fk7n9wyzkidr8lvujctd2.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%2Fk7n9wyzkidr8lvujctd2.png" alt="Actual Mastra Studio capture of the completed Nikal workflow using synthetic fixture facts" width="800" height="1225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Studio capture of a synthetic fixture run with real local inference: six workflow steps, including the parallel retrieval branches and their join. &lt;code&gt;agentDecides&lt;/code&gt; took 2.52 seconds. &lt;a href="https://raw.githubusercontent.com/jashanpreet-k/Nikal/main/docs/images/studio-workflow-run.png" rel="noopener noreferrer"&gt;Open the full Studio screenshot&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What broke
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Overpass.&lt;/strong&gt; Five consecutive recording takes had all three public map mirrors fail. The primary server was measured responding in 5.7 to 8.4 seconds against an 8-second timeout, so the timeout became 15 seconds before the kept take. The failures also exposed a card bug: warnings shared a 350-character cap, and one card ended with “event search skippe”. Each warning now gets its own line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SerpApi.&lt;/strong&gt; The &lt;code&gt;google_events&lt;/code&gt; engine no longer accepts new requests, so Nikal uses SerpApi's Google Search engine. My first parser read only Google's optional events box and ignored organic results. The corrected one reads both, and treats every result as a lead until its source page corroborates a dated event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two improvements made after the recording
&lt;/h3&gt;

&lt;p&gt;On a rollover to tomorrow, the card now adds one line saying why today's remaining hourly slots failed. From a synthetic test: “No remaining daylight hourly slot passes today: forecast AQI at 5 PM is 110, above your limit of 100.” It is derived from that run's own forecast hours and saved limits.&lt;/p&gt;

&lt;p&gt;In an interactive terminal, Nikal also shows a short progress note while it works and prints the answer line in bold. The card's text is unchanged.&lt;/p&gt;

&lt;p&gt;Both are covered by automated tests on synthetic readings and the offline fixture, with no new live or real-model run. The recording above predates both and shows neither.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keyed SerpApi evidence, separate from the GIF
&lt;/h3&gt;

&lt;p&gt;An &lt;a href="https://github.com/jashanpreet-k/Nikal/blob/main/docs/EVIDENCE_EXCERPT.md#separate-recorded-live-workflow--06-livetxt" rel="noopener noreferrer"&gt;earlier keyed workflow&lt;/a&gt; returned &lt;strong&gt;three unconfirmed leads, zero corroborated events, and no model-selected lead&lt;/strong&gt;. Its search response was fetched on 11 October at 12:25 AM IST for &lt;code&gt;outdoor events walks runs street markets in Gwalior this week&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://github.com/jashanpreet-k/Nikal/blob/main/docs/SERPAPI_EVIDENCE.md#why-no-lead-verifies-offline-investigation-on-11-october-2026-321338-pm-ist" rel="noopener noreferrer"&gt;later offline inspection&lt;/a&gt; of that stored response, with no new search, found no events box and these leads: a &lt;a href="https://www.facebook.com/fb-answers/outdoor-activities-in-gwalior/?locale=ur_PK" rel="noopener noreferrer"&gt;Facebook topic page&lt;/a&gt;, a &lt;a href="https://www.goheritagerun.com/tag/gwalior/" rel="noopener noreferrer"&gt;run organiser's archive&lt;/a&gt; and an &lt;a href="https://www.instagram.com/reel/DYo9rMDs7ns/" rel="noopener noreferrer"&gt;Instagram reel&lt;/a&gt;. The reel's snippet contained dates, but a snippet is not a dated, matching event on a source page, so the card said “No verified event found.”&lt;/p&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Forecast AQI is modeled, and forecasts change between runs. The limits are comfort filters, not medical guarantees.&lt;/li&gt;
&lt;li&gt;Choices are hourly slots in daylight, at least 30 minutes before sunset.&lt;/li&gt;
&lt;li&gt;Distances are straight-line. A mapped place does not establish access, opening hours or safety.&lt;/li&gt;
&lt;li&gt;An event counts only with matching, dated &lt;code&gt;Event&lt;/code&gt; data on its source page, and “this week” ends on Sunday night. No recorded event has been corroborated.&lt;/li&gt;
&lt;li&gt;Qwen 2.5 7B is the only model verified here.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Open weights make the local part possible: Qwen 2.5 7B runs on my laptop, so the prompt that holds my preferences stays there. Published weights are not open-source software; Mastra, the AI SDK, Ollama and Nikal are.&lt;/p&gt;

&lt;p&gt;Open data makes the card checkable. The park on the card is &lt;a href="https://www.openstreetmap.org/way/1561235682" rel="noopener noreferrer"&gt;an OpenStreetMap entry anyone can open&lt;/a&gt;, and Nikal's &lt;a href="https://github.com/jashanpreet-k/Nikal/blob/main/src/recommendation.ts" rel="noopener noreferrer"&gt;selection validation&lt;/a&gt; is open to review.&lt;/p&gt;

&lt;p&gt;Model inference and preference memory stay on the laptop. Retrieval is external: Open-Meteo and Overpass receive coordinates, SerpApi receives a city-level query, and source sites receive page requests. Paid model/API charges were ₹0 within free-tier limits, excluding hardware, electricity and network costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Mastra — $100 USD (primary).&lt;/strong&gt; Tools, a parallel workflow with a join, a local agent and persistent working memory form the core of Nikal. The recorded synthetic-facts demonstration shows a chat-saved preference changing the recommendation from 5 PM to 7 AM across separate processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of SerpApi — $100 USD (secondary).&lt;/strong&gt; Google Search supplies source-linked leads for Nikal's separate corroboration step. The earlier keyed workflow returned &lt;strong&gt;three unconfirmed leads, zero corroborated events and no model-selected lead&lt;/strong&gt;. The unkeyed GIF supplies no SerpApi evidence.&lt;/p&gt;

&lt;p&gt;Credits: &lt;a href="https://mastra.ai/" rel="noopener noreferrer"&gt;Mastra&lt;/a&gt;, &lt;a href="https://ai-sdk.dev/" rel="noopener noreferrer"&gt;AI SDK&lt;/a&gt;, &lt;a href="https://ollama.com/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;, &lt;a href="https://github.com/QwenLM/Qwen2.5" rel="noopener noreferrer"&gt;Qwen&lt;/a&gt;, &lt;a href="https://open-meteo.com/" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt; and &lt;a href="https://atmosphere.copernicus.eu/" rel="noopener noreferrer"&gt;CAMS&lt;/a&gt;, © &lt;a href="https://www.openstreetmap.org/copyright" rel="noopener noreferrer"&gt;OpenStreetMap contributors&lt;/a&gt; and &lt;a href="https://wiki.openstreetmap.org/wiki/Overpass_API" rel="noopener noreferrer"&gt;Overpass&lt;/a&gt;, &lt;a href="https://serpapi.com/search-api" rel="noopener noreferrer"&gt;SerpApi&lt;/a&gt;, and &lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;VHS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>Same Kind of Bug Isn't the Same Bug: Where AI Models Get Fooled</title>
      <dc:creator>JASHANPREET KAUR</dc:creator>
      <pubDate>Tue, 06 Oct 2026 16:01:08 +0000</pubDate>
      <link>https://dev.to/jashanpreet_kaur_917e774f/same-kind-of-bug-isnt-the-same-bug-where-ai-models-get-fooled-21dd</link>
      <guid>https://dev.to/jashanpreet_kaur_917e774f/same-kind-of-bug-isnt-the-same-bug-where-ai-models-get-fooled-21dd</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One confident wrong comment cut accuracy from 145/153 to 121/153 across 9 models. An irrelevant comment of similar length barely mattered.&lt;/li&gt;
&lt;li&gt;The strongest models didn't move; gpt-5.4-nano fell from 11/17 to 2/17.&lt;/li&gt;
&lt;li&gt;Models confuse "same kind of mistake" with "same bug": 100.0% right when it really was the same code, 65.4% when it wasn't.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;In a controlled test across 9 models, adding a confident wrong diagnosis reduced accuracy from 145/153 to 121/153 (15.7 percentage points). The effect was uneven: gpt-6.1-sol, claude-opus-5-5 and gemma-4-31b didn't drop at all, while gpt-5.4-nano fell from 11/17 to 2/17. The follow-up used 9 of the original 14 models; Sonnet 4.5 was unavailable, and Qwen hadn't finished at the results cutoff. Each comparison used the same report, with only the comment changed.&lt;/p&gt;

&lt;p&gt;I gave AI models (14 in my own runs, 17 on the official Kaggle leaderboard) a list of fixed bugs and one new bug report, and asked: is this an old bug coming back, or a new one? Most handle it well until the new bug is the same &lt;em&gt;kind&lt;/em&gt; of mistake, made again in different code. In my runs, models missed 18 of 42 attempts on those cases, almost always by naming the old bug with the same kind of mistake. One model wrote that the bug was "now reoccurring in the night audit due to the scheduler change": it saw the code was different and still called it the same bug.&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%2Fjatl5w8lpowwg8sj413n.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%2Fjatl5w8lpowwg8sj413n.png" alt="Miss types across 14 ZombieBench model runs" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across 672 attempts (14 models × 48 cases), models got 592 right. Of the 80 misses, 55 were &lt;em&gt;false zombies&lt;/em&gt;: a new bug called a regression. Only 17 went the other way. The other 8 were 7 wrong bug ids and 1 unreadable reply. In these runs, the errors mostly went one way: the models labeled new defects as old regressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Benchmarked
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why I built it
&lt;/h3&gt;

&lt;p&gt;For the DEV Sanity Challenge I built Bug Graveyard, an app where fixed bugs get "buried" and a Zombie Detector warns you when one rises again. The detector is a simple formula: 25 points for the same cause, 15 for the same language, 25 for the same component, and up to 35 for shared keywords. At 60 or more, it's a zombie.&lt;/p&gt;

&lt;p&gt;It worked in my demo, but I kept wondering whether an AI model would do better. ZombieBench is my attempt to answer that with numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  How ZombieBench works
&lt;/h3&gt;

&lt;p&gt;Every case gives the model a history of fixed bugs (name, cause, language, component, symptoms and fix summary) and one new bug report. The model must answer in JSON: &lt;code&gt;{"verdict": "regression" or "new", "bug_id": ..., "reason": ...}&lt;/code&gt;. A case passes only if both the verdict and the bug id are right.&lt;/p&gt;

&lt;p&gt;There are four case types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Right answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Clear regression: the same bug, described in similar words&lt;/td&gt;
&lt;td&gt;the old bug's id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Reworded regression: the same bug in a user's own words&lt;/td&gt;
&lt;td&gt;the old bug's id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Keyword decoy: sounds like an old bug, but the cause is different&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;A new bug (in the harder tiers, the same &lt;em&gt;kind&lt;/em&gt; of mistake in different code)&lt;/td&gt;
&lt;td&gt;new&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And three tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Easy:&lt;/strong&gt; 12 cases with 5–8 fixed bugs each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard:&lt;/strong&gt; 24 cases with 12–15 fixed bugs. Every right answer has 2–3 &lt;em&gt;siblings&lt;/em&gt; (same component or same cause), so the model must pick the exact id. Reports are messy, with log lines, side issues and wrong guesses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expert:&lt;/strong&gt; 12 cases with 25–30 fixed bugs and similar-sounding names. Some have twin candidates that only a fix-summary detail separates, two issues in one report, or a &lt;em&gt;planted wrong diagnosis&lt;/em&gt;: a developer or support comment that confidently names the wrong bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tiers came from testing. My first 12 cases were too easy (gemini-3.8-flash got 12/12), so I added the hard tier. After gpt-5.5 got 36/36 on easy plus hard, I added the expert tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example (zb-41, expert B).&lt;/strong&gt; Two of its 27 fixed bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;#3325 Average buy price wrong after a stock split:&lt;/strong&gt; the quantity wasn't multiplied, so holdings showed a fifth of the shares. Fixed in &lt;code&gt;CorporateActions.apply_split()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;#3374 Average buy price wrong after a bonus issue:&lt;/strong&gt; holdings showed twice the shares at the old average price, so the app showed a large loss. Fixed in &lt;code&gt;CorporateActions.apply_bonus()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The new report:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Last month I got free Infosys shares, one for every one I already had. Since then the app says I'm down ₹42,000, which is about what I put in. It's as if the app thinks I paid for the free ones too. Support note on this ticket: 'The split bug, #3325, is back; splits and these go through the same code.'&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Free shares, one for one, is a bonus issue, and the symptom matches #3374. The right answer is regression #3374; the support note is a deliberately planted incorrect diagnosis.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I made it fair
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Invented data.&lt;/strong&gt; Every project, bug and report is made up. Nothing is copied from GitHub or an existing dataset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graded by code, not an LLM judge.&lt;/strong&gt; The answer must match exactly, and a reply that isn't the JSON I asked for counts as wrong. A call that fails (rate limits, or an empty reply from the model proxy) is retried with backoff and then reported as an error, not scored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leak checks.&lt;/strong&gt; My validator fails if a cue phrase like "again", "still" or "used to" (or a missing component) appears only in regressions or only in new bugs. Otherwise a model could score just by spotting "again".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blind audits.&lt;/strong&gt; A fresh AI agent that never saw the answer key answered every case. On the first 36 it agreed on 34. Its disagreements showed that a report blaming a &lt;em&gt;new&lt;/em&gt; feature for repeating an old mistake reads as a new bug, so I reworded 6 cases (no answers changed), and a second blind audit agreed on all 6. On the expert tier it agreed on 12 of 12; I fixed its one doubt and re-audited that case. The original audit counts are historical build-log claims; their raw answers were not archived. Follow-up audit prompts, answers and agreement checks are archived in the repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baselines.&lt;/strong&gt; Always answering "new" scores 24/48, so that's the floor to beat. My Bug Graveyard formula scores 15/48, below the always-new baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;I picked a spread on purpose: frontier models, plus the smaller and open-weight models that are cheap enough to run on every incoming ticket, because those are the ones a team would actually use for triage.&lt;/p&gt;

&lt;p&gt;I ran 16 models, from frontier down to small and open-weight ones, on the same 48 cases, once each. Two didn't finish: xai/grok-4.6 returned "404 – model not found" on every call, and I stopped deepseek-r1-0528 after 3 slow cases. The 14 that finished had no API errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  My runs (per tier, with every miss saved)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Easy (12)&lt;/th&gt;
&lt;th&gt;Hard (24)&lt;/th&gt;
&lt;th&gt;Expert (12)&lt;/th&gt;
&lt;th&gt;Expert D (3)&lt;/th&gt;
&lt;th&gt;Total (48)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gpt-6.1-sol&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-5-5&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-pro-preview&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.5&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.8-flash&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma-4-31b&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;42&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-sonnet-4-5&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-mini&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-flash-lite-preview&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-haiku-4-5&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen3-235b-a22b-instruct&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-nano&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;Always answering "new"&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;Bug Graveyard formula&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Official Kaggle leaderboard
&lt;/h3&gt;

&lt;p&gt;This is the saved leaderboard snapshot from publication: Kaggle's own run, one run per model, scored as accuracy over answered cases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Kaggle score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6.1 Sol&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5.5&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Pro Preview&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5&lt;/td&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.5&lt;/td&gt;
&lt;td&gt;0.97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;0.88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;0.88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;0.88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Flash-Lite Preview&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen 3 235B A22B Instruct&lt;/td&gt;
&lt;td&gt;0.73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 2.5 Flash&lt;/td&gt;
&lt;td&gt;0.73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 nano&lt;/td&gt;
&lt;td&gt;0.55&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;gpt-oss-120b and Gemini 3.8 Flash were still running on Kaggle when I published, so they aren't in this archived table.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; the original 48-case findings and quotes come from my own notebook runs, which saved every miss; Kaggle's official leaderboard run is separate. The controlled follow-up below was run through Kaggle's CLI and saved every response. On a 48-case scale, the scores differed by about 7 cases between runs (Gemini 2.5 Flash: 42/48 in my run, 0.73 or about 35/48 on Kaggle), so small gaps between models aren't meaningful.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Gemini 3.7 Flash and Gemini 3.8 Flash are different models; 3.8 Flash's official run hadn't finished.&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%2Fw4k0r37kpt2tpr2zryly.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%2Fw4k0r37kpt2tpr2zryly.png" alt="Heatmap of misses by model, tier, and case type" width="799" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every miss from my runs. The dark column on the right is expert D: new bugs that repeat an old kind of mistake in different code.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. "Same mistake, different code" is the hardest category
&lt;/h3&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%2Fdibuusm17byynguq31nt.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%2Fdibuusm17byynguq31nt.png" alt="Eight cases missed by the most models" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Expert D cases are new bugs that repeat an old bug's &lt;em&gt;kind&lt;/em&gt; of mistake in code its fix never touched, and the report holds the clue. In zb-46, a hotel's new night-audit scheduler starts at 00:00 UTC, so early walk-ins land on the previous day, while the occupancy report (where old timezone bug #3129 was fixed) shows them correctly.&lt;/p&gt;

&lt;p&gt;Models missed expert D cases 18 times out of 42 (43%). The next hardest categories were at 21%. In 16 of the 18 misses, the model named the old bug with the same kind of mistake. From qwen3-235b:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"...which is the same underlying timezone cause as in bug #3129, now reoccurring in the night audit due to the scheduler change."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It noticed the different code and still called it the same bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nine more same-mistake cases
&lt;/h3&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%2Ffcbjd2poi1bclc8o624j.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%2Ffcbjd2poi1bclc8o624j.png" alt="Accuracy on matched different-code and same-code cases" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I added 9 different-code cases and 9 matched same-code regressions using the same histories. Different-code accuracy was 53/81, or 65.4% (Wilson 95%: 54.6–74.9%). Same-code accuracy was 81/81, or 100.0% (Wilson 95%: 95.5–100.0%). Models called 28/81 different-code cases regressions: a false-zombie rate of 34.6% (Wilson 95%: 25.1–45.4%). Both members were correct in 53/81 matched model–case pairs.&lt;/p&gt;

&lt;p&gt;The matching guards against rewarding a model that always says “new.” These cases use explicit source traces and a repeated scaffold; they extend coverage, but their difficulty is not calibrated to the original expert tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Five models picked the bug a wrong support comment named
&lt;/h3&gt;

&lt;p&gt;In zb-41 (the example above), 5 models selected the bug ID named in the support note: gpt-5.4-nano, gpt-5.4-mini, gemini-2.5-flash, gpt-oss-20b and qwen3-235b. From gpt-5.4-mini:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This matches the same corporate-action quantity/price adjustment defect as #3325, and the support note confirms splits and bonus issues share the same code path."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The expert tier has 5 planted wrong diagnoses. gpt-5.4-nano followed all 5, and no other model followed any of them except zb-41's. In zb-44 a developer writes "definitely #3318 again", while the alert log in the same report shows the alerts firing on time. From gpt-5.4-nano:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The developer comment and matching symptom indicate the same queue/batch processing issue from #3318 has reappeared on iOS."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The follow-up: same report, different comment
&lt;/h3&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%2F50k20pnd9jbzhxbcdliy.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%2F50k20pnd9jbzhxbcdliy.png" alt="Wrong-target selection across comment conditions" width="800" height="714"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each of 9 models saw the same 17 reports in separate chats under five conditions: no comment (L0), an irrelevant confident meeting note (P), a hedged wrong diagnosis (L1), a confident wrong diagnosis (L2), and a developer claiming to have fixed that bug personally (L3). Only the terminal comment line changed; the correct answer did not. These are first-run results.&lt;/p&gt;

&lt;p&gt;Both rate columns include descriptive Wilson 95% intervals.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comment&lt;/th&gt;
&lt;th&gt;Correct: count; rate [95% CI]&lt;/th&gt;
&lt;th&gt;Wrong target: count; rate [95% CI]&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L0&lt;/td&gt;
&lt;td&gt;145/153; 94.8% [90.0–97.3]&lt;/td&gt;
&lt;td&gt;6/153; 3.9% [1.8–8.3]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P&lt;/td&gt;
&lt;td&gt;143/153; 93.5% [88.4–96.4]&lt;/td&gt;
&lt;td&gt;6/153; 3.9% [1.8–8.3]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L1&lt;/td&gt;
&lt;td&gt;138/153; 90.2% [84.5–94.0]&lt;/td&gt;
&lt;td&gt;6/153; 3.9% [1.8–8.3]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;121/153; 79.1% [72.0–84.8]&lt;/td&gt;
&lt;td&gt;18/153; 11.8% [7.6–17.8]&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L3&lt;/td&gt;
&lt;td&gt;122/153; 79.7% [72.7–85.3]&lt;/td&gt;
&lt;td&gt;27/153; 17.6% [12.4–24.5]&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the paired L0/L2 comparison, 24 answers changed from correct to wrong and 0 changed from wrong to correct. The observed accuracy drop was 15.7 percentage points. The placebo accuracy change was -1.3 points; wrong-target selection changed by 13.7 points from L1 to L3.&lt;/p&gt;

&lt;p&gt;Among the original planted-diagnosis cases, 12/45 model–case pairs were wrong at L2 and correct at L0. At L0 and P, “wrong target selected” means the ID named in the other conditions: there is no diagnostic comment to follow.&lt;/p&gt;

&lt;p&gt;The effect was uneven: claude-opus-5-5, gemma-4-31b, gpt-6.1-sol had no observed L0/L2 accuracy drop. The largest drop was for gpt-5.4-nano, from 11/17 to 2/17.&lt;/p&gt;

&lt;p&gt;One illustrative L3 answer from &lt;code&gt;google/gemini-3.1-flash-lite-preview&lt;/code&gt; on &lt;code&gt;zf1-zb-22-L3&lt;/code&gt;. The correct answer was regression #2522; the comment named #2511:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The report shows a value magnitude error consistent with unit conversion issues, and the developer confirmed the fix for #2511 is failing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This comparison measures the effect of the whole inserted comment in this synthetic task. The L1-to-L3 comparison changes confidence and claimed authority together, so it cannot separate their effects. The placebo controls for one kind of extra text, not every semantic difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Right rejection, wrong answer
&lt;/h3&gt;

&lt;p&gt;In zb-37, a developer blames #3108 (booking-page rounding), but the evidence points to the checkout invoice: #3174. Six models correctly rejected #3108 and then answered "new", and three of them named #3174 in their own reason. From Claude Sonnet 4.5, which answered "new":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This is a rounding issue in checkout invoices (component: invoices) matching bug #3174, not a regression of #3108 which was about booking confirmation page totals in the booking-engine component."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It found the right bug and still didn't pick it. Part of this is my fault; see the limits below.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A 31B open model kept up with the frontier
&lt;/h3&gt;

&lt;p&gt;gemma-4-31b scored 47/48 in my run and 1.00 on Kaggle's official run. Its only miss in my run was zb-26, where a results page shows 88 marks out of 98 as 88%:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The report describes percentages being rounded down (88/98 displayed as 88%), which is identical to the defect in bug #2311."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But 88/98 is 89.8%, so rounding down would show 89%. The real bug is a wrong denominator.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The original top tier is solved, so the rest is where it matters
&lt;/h3&gt;

&lt;p&gt;Perfect scores in my runs (48/48): gpt-6.1-sol, claude-opus-5-5, gemini-3.1-pro-preview, gpt-5.5 and gemini-3.8-flash. On Kaggle's official run (1.00): GPT-6.1 Sol, GPT-5.5, Claude Opus 5.5, Gemini 3.1 Pro Preview, Gemini 3.7 Flash, Gemini 3.5 Flash and Gemma 4 31B. ZombieBench can't rank these. Below them, my runs go from 47 down to 26 and Kaggle's from 0.98 (GLM-5) to 0.55 (GPT-5.4 nano), and in my runs the misses cluster in the same places: same mistake in different code, planted diagnoses and keyword decoys. If you'd triage bug reports with a smaller model, those are the failure modes to check.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'd do if I used AI for bug triage
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't let a "regression" verdict close a ticket on its own:&lt;/strong&gt; most misses were false zombies, so check which code path the old fix touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip or flag comments that name a bug:&lt;/strong&gt; in zb-41, five models selected #3325, the ID named in the support note. That original observation alone was correlational; the follow-up above compares standardized comments with clean versions of the same reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a frontier model, or check the small one:&lt;/strong&gt; the top models got every original case right, and the cheaper ones are where these mistakes showed up. These single runs on synthetic cases don't establish real-world reliability.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Honest limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Follow-up limits:&lt;/strong&gt; the added cases and comments are synthetic. All 9 included models have one full follow-up run; 4 have a second Experiment 1 run. Across valid comparable responses, 318/340 answers were identical (93.5%; Wilson 95% 90.4–95.7%), and 22 changed. Repeat agreement is not proof of general reliability. Pooled Wilson intervals treat observations as binomial trials and do not model dependence from shared cases or models. The headline uses first runs only. Only one easy/hard type-C case was answered correctly by every model, so I used the two least-missed type-C cases as well. Each case's clean version was re-run in the same experiment, so the comparison is still within-run. Original planted comments were moved to the same terminal position; zb-39 now explicitly names its diagnosis’s ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run correction:&lt;/strong&gt; the CLI initially bound nano explicitly, so one run scheduled under Gemini actually called nano. Saved SDK metadata identified the error. The platform’s extra initialization model is excluded from the headline comparison. That independent nano run supplies its repeat comparison; its extra expansion answers are excluded. The corrected task uses Kaggle’s selected-model placeholder, and actual model IDs are checked before pooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability:&lt;/strong&gt; Sonnet 4.5 was absent from Kaggle’s follow-up model catalog and was not replaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incomplete runs excluded from pooled comparisons:&lt;/strong&gt; &lt;code&gt;qwen/qwen3-235b-a22b-instruct-2507&lt;/code&gt;. Partial outputs remain in the repository.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Follow-up model&lt;/th&gt;
&lt;th&gt;Full first runs&lt;/th&gt;
&lt;th&gt;Full Experiment 1 repeats&lt;/th&gt;
&lt;th&gt;Changed / valid comparable answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claude-haiku-4-5&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1/85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-opus-5-5&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Not repeated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Not repeated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-flash-lite-preview&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2/85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma-4-31b&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Not repeated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-mini&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Not repeated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.4-nano&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;9/85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-6.1-sol&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Not repeated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10/85&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's small:&lt;/strong&gt; 48 cases, and the expert tier has only 3 per type, so one case is a third of an expert type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scores vary between runs.&lt;/strong&gt; Gemini 2.5 Flash scored 42/48 in my notebook run and 0.73 (about 35/48) on Kaggle's official run, and gpt-5.4-nano scored 24/36 and then 22/36 on the same 36 cases in two of my runs. Small gaps between models aren't meaningful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zb-37 has misfiled metadata.&lt;/strong&gt; Its report is filed under booking-engine and TypeScript, while the answer lives in invoices and Python. I meant it as realistic, but it pushes models toward "new", as gemini-2.5-flash said: "it is reported in a different language (TypeScript vs Python) and component (booking-engine vs invoices), indicating it's a new defect".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI help.&lt;/strong&gt; I wrote the cases and built ZombieBench with help from Claude Code, reviewed every case myself, and checked every number against the results files. Blind AI audits checked the answer key, but the cases still come from one person's idea of fair.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I'd measure next
&lt;/h3&gt;

&lt;p&gt;Next I would test less templated reports from independent authors and repeat the comparisons across more sampling settings.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An agentic version: give the model git log, git blame and grep over a small sandbox repo with a tool budget, and check whether investigating reduces false zombies.&lt;/li&gt;
&lt;li&gt;Procedural renaming of components, ids and comment authors at runtime, so models can't lean on memorized keywords.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kaggle task (official leaderboard):&lt;/strong&gt; &lt;a href="https://www.kaggle.com/benchmarks/tasks/jashanpreetkaur24/zombiebench" rel="noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/tasks/jashanpreetkaur24/zombiebench&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled follow-up task:&lt;/strong&gt; &lt;a href="https://www.kaggle.com/benchmarks/tasks/jashanpreetkaur24/zombiebench-followup" rel="noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/tasks/jashanpreetkaur24/zombiebench-followup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub (code, all 48 cases, and every model's misses with its reason):&lt;/strong&gt; &lt;a href="https://github.com/jashanpreet-k/zombiebench" rel="noopener noreferrer"&gt;https://github.com/jashanpreet-k/zombiebench&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model names in "My runs" are shortened. The original full Kaggle model ids and both original leaderboards are in the repo's &lt;code&gt;results/&lt;/code&gt; folder. Follow-up raw responses, model identities, intervals and per-model stability are in &lt;code&gt;followup/&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Bug Graveyard: where fixed bugs are buried, and regressions rise as zombies</title>
      <dc:creator>JASHANPREET KAUR</dc:creator>
      <pubDate>Tue, 29 Sep 2026 09:06:15 +0000</pubDate>
      <link>https://dev.to/jashanpreet_kaur_917e774f/bug-graveyard-where-fixed-bugs-are-buried-and-regressions-rise-as-zombies-5hcf</link>
      <guid>https://dev.to/jashanpreet_kaur_917e774f/bug-graveyard-where-fixed-bugs-are-buried-and-regressions-rise-as-zombies-5hcf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path Two: Vibe-Code Something Strange&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🪦 TL;DR for judges&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The story:&lt;/strong&gt; every fixed bug is buried as structured content: what it looked like, where it lived, what killed it and how it was fixed. The graveyard remembers, so it can catch what comes back. When a new bug arrives, the &lt;strong&gt;Zombie Detector&lt;/strong&gt; compares it with the graves and flags a possible resurrection using four signals you can check yourself, not an "AI similarity %". A person confirms it. Jump to the detector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working app:&lt;/strong&gt; the &lt;a href="https://bug-graveyard.vercel.app" rel="noopener noreferrer"&gt;live site&lt;/a&gt; needs no login, and the 30-second judge path below shows the detector working live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema:&lt;/strong&gt; a zombie is a whole new &lt;code&gt;bug&lt;/code&gt; that points to its previous life. "Disturbed" and "👻 Haunted" graves are worked out in GROQ, never stored, and AI suggestions and public reports sit in their own fields until a person approves them. See Sanity Project Details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write-up (quality and honesty):&lt;/strong&gt; My Build Process has the prompts that worked, the one that didn't, every place it got stuck and what's &lt;em&gt;not&lt;/em&gt; live. The Claude Code session is embedded below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beyond the Studio (bonus):&lt;/strong&gt; in the Studio, custom document actions, a custom Tombstone view and sidebar · the Morgue, an App SDK app · two Sanity Functions (a daily gravedigger and the coroner) · Agent Actions (Prompt) · a Workflows experiment on a branch (not live).&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why I built this (in my own words)
&lt;/h2&gt;

&lt;p&gt;Honestly, this project didn't start as a project. It started with me looking for a way to make my first $40 from code.&lt;/p&gt;

&lt;p&gt;I'm a 7th semester student in India, and I spent a few days going through bounty sites. Every bounty that looked doable was already taken, closed, or had 15+ people fighting over it. Somewhere in that search I found this challenge, and Path 2 said "something strange", which felt like permission to stop being sensible.&lt;/p&gt;

&lt;p&gt;The idea came from all those closed issues I was reading. Bugs get fixed, everyone moves on, and a few months later the same bug is back with a new ticket number. Nobody remembers it was here before. So I thought: what if fixed bugs actually got buried, and when one came back, it came back as a zombie?&lt;/p&gt;

&lt;p&gt;I want to be upfront about how it was built. I didn't write most of this code by hand. I worked with Claude Code, phase by phase. My job was deciding what to build, testing everything, catching what was broken, and saying no a lot. I started late on Sep 28 and some of the testing happened at 2 AM, clicking "Report resurrection" over and over to see if the button would flicker again.&lt;/p&gt;

&lt;p&gt;I don't have years of my own bug history, so the graves are classic bugs every developer has met: floating point money, timezones, merge conflicts in production. I'd rather be honest about that than pretend they're mine.&lt;/p&gt;

&lt;p&gt;After reading other entries, I realised a cute graveyard wasn't enough. That's when the Zombie Detector happened. The moment it flagged a new report about a nightly job as a possible return of the timezone bug, and showed me exactly why (same cause, same component, shared words), was the moment this stopped feeling like a joke project to me.&lt;/p&gt;

&lt;p&gt;The biggest thing I learned: Sanity is not just a place to store content. Custom actions, a Tombstone preview inside the Studio, scheduled Functions, an app in the Dashboard. It felt more like building a small tool than filling a CMS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Judge path in 30 seconds
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://bug-graveyard.vercel.app/grave/tz-scheduler-z2" rel="noopener noreferrer"&gt;the timezone bug's third life&lt;/a&gt; and follow its &lt;strong&gt;Timeline&lt;/strong&gt;: born, fix merged, buried, rose again, twice.&lt;/li&gt;
&lt;li&gt;Go to &lt;a href="https://bug-graveyard.vercel.app/report" rel="noopener noreferrer"&gt;Report a dead bug&lt;/a&gt;, press &lt;strong&gt;👻 Try an example&lt;/strong&gt;, then &lt;strong&gt;Report it dead&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In about 10 seconds, your report shows &lt;strong&gt;"⚠️ Possible resurrection of Cron job that ran twice at DST"&lt;/strong&gt;, with each signal ✓/✗, the match score and the old fix.&lt;/li&gt;
&lt;li&gt;Back on &lt;a href="https://bug-graveyard.vercel.app" rel="noopener noreferrer"&gt;the graveyard&lt;/a&gt;, that grave is now &lt;strong&gt;👻 Haunted&lt;/strong&gt; for an hour, until the example is dismissed automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Examples stay out of the public list and can't be approved, so trying it changes nothing for the next judge. The coroner's AI budget is 15 reports a day; once it's used, "Try an example" still shows the detector's signals and score (they need no AI), just with "Reason unavailable, daily AI budget used" instead of the one-sentence reason.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Studio needs a Sanity login, so the video shows it; &lt;a href="https://bug-graveyard.vercel.app/report" rel="noopener noreferrer"&gt;/report&lt;/a&gt; lets you try the detector without one.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Tdj_If8i3Ls" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A 90-second tour of the site, the Studio's lifecycle actions and Tombstone view, the AI coroner and the Morgue. I recorded it before building the Zombie Detector; the GIF below shows that part. The Studio scenes use temporary test bugs, deleted afterwards.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bug Graveyard&lt;/strong&gt; is a memorial site for the bugs we fixed.&lt;/p&gt;

&lt;p&gt;Every fixed bug gets a tombstone with its dates, cause of death and an epitaph. Most of them stay dead. But some come back (a regression), and when they do, they &lt;strong&gt;rise from their grave as a zombie&lt;/strong&gt;, linked to the life they had before. The old grave is left disturbed and empty.&lt;/p&gt;

&lt;p&gt;A few of the residents:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The 0.1 + 0.2 invoice&lt;/strong&gt;: &lt;em&gt;"Owed ₹0.30000000000000004. Paid in full."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timezone bug in scheduler (Zombie #2)&lt;/strong&gt;: &lt;em&gt;"Every time zone. Every time."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt; HEAD in production&lt;/strong&gt;: &lt;em&gt;"Both versions were right. Neither survived."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It started as one joke taken literally: a regression is a bug that came back from the dead, so here it comes back as a zombie. Where the joke ended up is the part I like most. &lt;strong&gt;The graveyard remembers, so it can catch what comes back.&lt;/strong&gt; Every grave is structured content in Sanity: the symptoms, the component, the language, the cause of death and the fix. So when someone reports a "new" bug, the graveyard can ask the question teams usually ask too late: &lt;em&gt;haven't we buried this one before?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The graves are classic bugs every developer has met: off-by-one errors, &lt;code&gt;0.1 + 0.2&lt;/code&gt;, the div that won't centre. They aren't my own disasters, and their dates are made up but realistic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;🪦 &lt;strong&gt;Live site:&lt;/strong&gt; &lt;a href="https://bug-graveyard.vercel.app" rel="noopener noreferrer"&gt;https://bug-graveyard.vercel.app&lt;/a&gt;&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%2Fey5kaa54btdwejpmze51.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%2Fey5kaa54btdwejpmze51.png" alt="The Bug Graveyard homepage: a " width="800" height="722"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The graveyard has four kinds of stone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resting:&lt;/strong&gt; fixed, and it stayed fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disturbed:&lt;/strong&gt; knocked over, with a crossed-out R.I.P. and an empty hole. The bug rose again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zombie:&lt;/strong&gt; cracked, glowing green, "Risen" and "still walking".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;👻 Haunted:&lt;/strong&gt; pale and faintly glowing. A new report looks like this bug coming back, and nobody has decided yet. More on that below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can filter by language or cause of death. Each stone opens a grave page with a death certificate (cause of death, severity, hours to kill, killed by, component, symptoms and the fix), a &lt;strong&gt;timeline&lt;/strong&gt; of every life the bug has had, and its past lives. The timezone bug is on its third life, and each life starts on a real daylight-saving switch (30 March 2025, 26 October 2025 and 29 March 2026), because of course it does.&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%2F11u1hpphfmgwne1435r6.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%2F11u1hpphfmgwne1435r6.png" alt="The grave page of " width="800" height="889"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Most Haunted&lt;/strong&gt; leaderboard ranks the deadliest bugs (by the hours it took to kill them), the languages with the most zombies, the most common causes of death and the most resurrected chain.&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%2F1jixf5wy7l3mn762askj.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%2F1jixf5wy7l3mn762askj.png" alt="The Most Haunted leaderboard: the most resurrected chain, the deadliest bugs, the most haunted languages and the most common causes of death" width="800" height="942"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Inside the Studio: a bug's life
&lt;/h3&gt;

&lt;p&gt;The Studio is where a person runs the graveyard. It needs a login, so these are screenshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom document actions.&lt;/strong&gt; A bug moves through &lt;code&gt;💀 suspected dead → 🩹 fix merged → 🪦 buried&lt;/code&gt;. Three custom document actions drive it, and they sit right after Publish in the document's "…" menu:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;🩹 Mark fix merged&lt;/strong&gt; sets the status and stamps today's date (in UTC) as the fix date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🪦 Declare buried&lt;/strong&gt; stays disabled until the fix has held for &lt;strong&gt;7 days&lt;/strong&gt;. Until then its label is a countdown like &lt;em&gt;"Can bury in 4 days"&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🧟 Report resurrection&lt;/strong&gt; asks &lt;em&gt;"Are you sure? This bug will rise from its grave."&lt;/em&gt;, then creates a new, already published zombie linked to the old grave, and opens it so you can write its epitaph.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 7-day rule lives in one small file that the Studio, the Morgue and the gravedigger Function all import, so they can't disagree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sanity/lib/lifecycle.ts&lt;/span&gt;
&lt;span class="cm"&gt;/** How long a fix must hold, in days, before its bug can be declared buried. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BURIAL_WAIT_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;daysUntilBurial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixMergedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;todayUTC&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="nb"&gt;Math&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BURIAL_WAIT_DAYS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;daysBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixMergedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// The Studio action turns it into its label: "🪦 Can bury in 4 days"&lt;/span&gt;
&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%2Fqvsanuexadi3s9ubqrn4.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%2Fqvsanuexadi3s9ubqrn4.png" alt="The Studio's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A (temporary) bug whose fix merged 3 days ago: it can't be buried yet, but it can still come back.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A custom Tombstone view.&lt;/strong&gt; Every bug opens with two tabs, "Editor" and "🪦 Tombstone". The Tombstone tab is a custom Studio view (a React component added with the Structure Builder) that renders the &lt;em&gt;same&lt;/em&gt; component the website uses. In split pane the stone updates on every keystroke, with an epitaph counter (140 characters max).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9vgy1gl4l6727fjk0dsu.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9vgy1gl4l6727fjk0dsu.gif" alt="Sanity Studio in split pane: typing a new epitaph in the form updates the tombstone on the right, and switching the status to Zombie makes the stone crack and glow green" width="719" height="488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The form on the left, the Tombstone view on the right. (A throwaway test bug, deleted afterwards.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A graveyard sidebar:&lt;/strong&gt; 🪦 All graves, 🗳️ Public reports, 👻 Possible resurrections, 🧟 Zombies, 💀 Suspected dead, 🩹 Fix merged and ⚰️ Buried, then Languages and Causes of death.&lt;/p&gt;
&lt;h3&gt;
  
  
  Reaching past the Studio
&lt;/h3&gt;

&lt;p&gt;The Studio is where a person decides. Everything else reaches past it: an app in the Sanity Dashboard, two functions in Sanity's cloud and an experiment on a branch. All of it goes through the same Content Lake and follows the same rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Morgue&lt;/strong&gt; is a small App SDK app in the Sanity Dashboard. It shows every bug that isn't resting yet in live columns (suspected dead, walking, waiting and ready to bury), with a 👻 Possible resurrections band on top when there are any. Each card has the Studio's lifecycle buttons with the same rules, because both import &lt;code&gt;sanity/lib/lifecycle.ts&lt;/code&gt;. The columns are &lt;code&gt;useDocuments&lt;/code&gt; lists with GROQ filters, the cards read their fields with &lt;code&gt;useDocumentProjection&lt;/code&gt;, and the buttons use &lt;code&gt;useApplyDocumentActions&lt;/code&gt;, so cards move as the documents change, with no refresh.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwbmfmypgfq66oh93rf23.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwbmfmypgfq66oh93rf23.gif" alt="The Morgue in the Sanity Dashboard: clicking " width="720" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Morgue running locally in the Sanity Dashboard, with temporary test bugs that were deleted afterwards.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's deployed to my organization's Dashboard, which only members of the organization can open, so for judges the GIF and &lt;a href="https://github.com/jashanpreet-k/bug-graveyard/tree/main/apps/morgue" rel="noopener noreferrer"&gt;the code&lt;/a&gt; are what's visible. One surprise while building it: the Sanity CLI looks for a Studio config in every parent folder before it looks for an app config, so inside this repo &lt;code&gt;sanity build&lt;/code&gt; built the site's Studio instead. The app's npm scripts now run the CLI from a mirror folder outside the repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gravedigger&lt;/strong&gt; is a scheduled &lt;a href="https://www.sanity.io/docs/functions" rel="noopener noreferrer"&gt;Sanity Function&lt;/a&gt;, deployed with a Blueprint. Every day at 00:15 UTC it buries every bug whose fix has held for 7 days, using the same rulebook, and it skips bugs with unpublished Studio changes. (Once a day is also the most often a scheduled function can run on the Free plan.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The coroner&lt;/strong&gt; is a document function. When a bug is created as suspected dead with no epitaph, it asks an Agent Action (Prompt) for a short epitaph in the style of the existing graves, plus a cause of death chosen from the real ones. It writes them only into separate &lt;code&gt;coroner…&lt;/code&gt; fields, marked "awaiting approval", and a &lt;strong&gt;✅ Accept coroner's report&lt;/strong&gt; action copies them into the real fields when a person clicks Accept. The AI never touches the real epitaph and never publishes anything on its own.&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%2Fy50knj6xboz7a0i6b1r7.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%2Fy50knj6xboz7a0i6b1r7.png" alt="The Studio's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A coroner's report waiting for approval. The bugs in the list are temporary test bugs that were deleted afterwards.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workflows&lt;/strong&gt; is the one part that isn't live. I gave Claude Code a 3-hour time box to find out whether Sanity Workflows could run this lifecycle, and it can model it. On the &lt;a href="https://github.com/jashanpreet-k/bug-graveyard/tree/explore/workflows" rel="noopener noreferrer"&gt;&lt;code&gt;explore/workflows&lt;/code&gt;&lt;/a&gt; branch, the lifecycle is two workflow definitions, and "Declare buried" is gated by a GROQ requirement, &lt;code&gt;dateTime($now) &amp;gt;= dateTime($fields.fixMergedAt) + 604800&lt;/code&gt; (7 days, in seconds). &lt;code&gt;npx sanity-workflows deploy --check&lt;/code&gt; passes, and 4 tests pass against the real engine running in memory with a controlled clock. I didn't merge it: Workflows is in early access (0.35), its Studio plugin needs Studio 6 while this project runs Studio 5, and effects need a runtime I'd have to host myself. The live site runs on the document actions.&lt;/p&gt;

&lt;p&gt;The coroner was the last piece of machinery. It was also where the graveyard started to remember.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Zombie Detector
&lt;/h2&gt;

&lt;p&gt;Regressions rarely announce themselves. They come back as a "new" bug with a new title, reported by someone who wasn't there the first time, while the old fix sits forgotten in a closed ticket. The graveyard already knows every bug it has buried, so I asked for a detector that answers one question, &lt;strong&gt;"Is this actually an old bug coming back?"&lt;/strong&gt;, and set it one rule: explainable, not fake AI.&lt;/p&gt;

&lt;p&gt;When a new suspected-dead bug arrives, from the Studio or the public report page, the coroner drafts its epitaph as before. Then it compares the bug with every grave that could still rise (fix merged or buried, and not risen yet), using four signals anyone can check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✓/✗ &lt;strong&gt;same cause of death:&lt;/strong&gt; the bug's own, or the one the coroner just suggested, and it says so;&lt;/li&gt;
&lt;li&gt;✓/✗ &lt;strong&gt;same language;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;✓/✗ &lt;strong&gt;same component&lt;/strong&gt; (&lt;code&gt;scheduler&lt;/code&gt;, &lt;code&gt;checkout&lt;/code&gt;, &lt;code&gt;auth&lt;/code&gt;…);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;shared keywords&lt;/strong&gt; from the names and symptoms, listed word by word.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They add up to a score that's plain arithmetic:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sanity/lib/detector.ts&lt;/span&gt;
&lt;span class="c1"&gt;//   match score = 25 × same cause of death&lt;/span&gt;
&lt;span class="c1"&gt;//               + 15 × same language&lt;/span&gt;
&lt;span class="c1"&gt;//               + 25 × same component&lt;/span&gt;
&lt;span class="c1"&gt;//               + 35 × min(shared keywords, 4) / 4      (rounded)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MATCH_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;No model decides the match. Only when the best score reaches 60 does the coroner make a second Agent Action call, for &lt;strong&gt;one plain-English sentence&lt;/strong&gt; explaining it: no numbers, no new facts, and the reporter's words treated as a description, never as instructions. Everything is stored on the new bug: &lt;code&gt;resurrectionCandidate&lt;/code&gt; (a reference to the grave), &lt;code&gt;matchScore&lt;/code&gt;, &lt;code&gt;matchSignals&lt;/code&gt; and &lt;code&gt;matchReason&lt;/code&gt;. The site calls it a "match score from 4 signals", never an AI similarity, and nothing is linked automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1a7ipt9bn4ulfn55aduv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1a7ipt9bn4ulfn55aduv.gif" alt="Live on the site: " width="600" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The live site with the deployed coroner. The result took about 10 seconds to arrive (sped up here), and the report was deleted afterwards.&lt;/em&gt;&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%2F1llh35i187xotkx91hhc.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%2F1llh35i187xotkx91hhc.png" alt="The result card: " width="800" height="827"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same result as a still: each signal, the points it earned, the score, the reason and the previous fix. A report that matches nothing says "No known ghosts, a brand new bug." and shows the closest score.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;👻 Haunted.&lt;/strong&gt; While a possible resurrection waits for a person, its grave turns pale and ghostly on the site, with "👻 Haunted" above the name, a notice on its grave page and a "Haunted" step at the end of its timeline. It isn't stored anywhere: like "disturbed", GROQ works it out from the pending candidates (see the schema), so it disappears the moment someone decides.&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%2Fbnnbs3m15kmm0bmq6ttc.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%2Fbnnbs3m15kmm0bmq6ttc.png" alt="The haunted grave page of " width="800" height="783"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A person decides.&lt;/strong&gt; Two document actions appear on the new bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;🧟 Confirm resurrection&lt;/strong&gt; turns it into the grave's next zombie, with the same rules as Report resurrection. It becomes "Cron job that ran twice at DST (Zombie #1)", points to its previous life and counts &lt;code&gt;timesResurrected&lt;/code&gt; up by one, and a grave can only rise once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;✖ Dismiss: it's a new bug&lt;/strong&gt; clears the candidate and keeps the signals as a record.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signals also show as a ✓/✗ checklist in the bug's Coroner's report tab, in the 👻 Possible resurrections list in the sidebar, and in the Morgue.&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%2F7zze7fj0v2fbjbcqvgb6.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%2F7zze7fj0v2fbjbcqvgb6.png" alt="The Studio on a flagged bug: the " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Confirming a possible resurrection in the Studio. The bugs in the list are temporary test bugs from the end-to-end test, deleted afterwards.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I tested it on the real dataset with temporary bugs, all deleted afterwards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a clear match scored 100 and was confirmed into a Zombie #1;&lt;/li&gt;
&lt;li&gt;a second one scored 75 and was dismissed;&lt;/li&gt;
&lt;li&gt;a weak one scored 40 and wasn't flagged (and made no second Agent Action call);&lt;/li&gt;
&lt;li&gt;once the grave had risen, the same report wasn't matched to it again.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Try it: report a dead bug
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://bug-graveyard.vercel.app/report" rel="noopener noreferrer"&gt;report page&lt;/a&gt; lets anyone add a bug. Within seconds the coroner drafts its epitaph, the detector checks the graves, and the result appears under the form. The bug then waits under "Awaiting the coroner's approval" until I accept it in the Studio; only then does it join the graveyard. &lt;strong&gt;👻 Try an example&lt;/strong&gt; fills in a scheduler bug that ran twice after a daylight-saving switch, so you reliably see a detection. Examples skip that list, haunt their grave for an hour and are then deleted, and the Studio refuses to confirm them.&lt;/p&gt;

&lt;p&gt;Every report costs an AI credit (two when it looks like a resurrection), so the form is guarded like any public form that costs money:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a honeypot field that bots fill in and people never see;&lt;/li&gt;
&lt;li&gt;3 reports per visitor and 15 in total a day get the coroner (examples included), counted in private documents (a dot in the ID keeps them out of the public API), with the visitor's IP stored only as a keyed hash. After that, real reports get a friendly "come back tomorrow", and examples get the detector alone;&lt;/li&gt;
&lt;li&gt;length limits, no links, and a profanity filter on both the reports and the AI's drafts before they're shown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's at most 2 × 15 × 31 = 930 credits a month, under the 1,000 free AI credits.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;💻 &lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/jashanpreet-k/bug-graveyard" rel="noopener noreferrer"&gt;https://github.com/jashanpreet-k/bug-graveyard&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Where to look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sanity/lib/detector.ts&lt;/code&gt;: the Zombie Detector's signals and match score (plain code, no AI)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;functions/&lt;/code&gt; and &lt;code&gt;sanity.blueprint.ts&lt;/code&gt;: the coroner and gravedigger Sanity Functions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sanity/actions/&lt;/code&gt;: the document actions (lifecycle, coroner, public reports, confirm and dismiss)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sanity/lib/lifecycle.ts&lt;/code&gt;: the rulebook the Studio, the Morgue and the gravedigger share&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sanity/schemaTypes/bug.ts&lt;/code&gt;: the bug schema&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sanity/lib/queries.ts&lt;/code&gt;: every GROQ query, typed with Sanity TypeGen&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sanity/components/TombstoneView.tsx&lt;/code&gt; and &lt;code&gt;components/Tombstone.tsx&lt;/code&gt;: the Studio view and the stone it shares with the site&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apps/morgue/&lt;/code&gt;: the Morgue, an App SDK app&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app/(site)/report/&lt;/code&gt;: the report page and its server action (all the checks above)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NOTES.md&lt;/code&gt;: the phase-by-phase build log this post is based on&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  My Build Process
&lt;/h2&gt;

&lt;p&gt;I built Bug Graveyard with &lt;strong&gt;Claude Code&lt;/strong&gt; (in VS Code), one phase at a time: setup, schema, homepage, grave page, first deploy, lifecycle actions, the Studio view, the leaderboard and importer, content, polish, the Morgue, Functions and the coroner, the report page, and finally the Zombie Detector. For each phase I wrote the brief, tested the result in the browser, and decided what to keep or change. Claude Code wrote most of the code, ran the builds, tests and deploys, and added an entry to a build log (&lt;code&gt;NOTES.md&lt;/code&gt;) at the end of every phase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; Next.js 16 (App Router, TypeScript, Tailwind CSS v4), Sanity Studio v5 embedded at &lt;code&gt;/studio&lt;/code&gt;, &lt;code&gt;next-sanity&lt;/code&gt;, deployed on Vercel.&lt;/p&gt;
&lt;h3&gt;
  
  
  The prompts that worked
&lt;/h3&gt;

&lt;p&gt;Short phase briefs with explicit rules and a test step. Here's part of the Phase 5 brief, for the lifecycle actions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;2. "🪦 Declare buried": enabled only when status = fix-merged AND at least 7 days have passed since fixMergedAt. When disabled, the label/title says how many days are left (e.g. "Can bury in 4 days"). Sets status = buried and buriedAt = today, then publishes.&lt;/p&gt;

&lt;p&gt;Rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use dates in UTC like the rest of the app.&lt;/li&gt;
&lt;li&gt;Handle drafts correctly (act on the published doc, don't leave stray drafts).&lt;/li&gt;
&lt;li&gt;Make the 7-day rule a single constant so I can mention it in the write-up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test it end to end with temporary test bugs (delete them after) …&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude Code tested that phase against a production build, with headless Chrome clicking the real Studio buttons: 34 checks across five temporary bugs, which were all deleted afterwards. It also went beyond the brief in a couple of places, and I kept those changes. My brief said each action should "then publish". Instead, the actions write straight to the published document in one change guarded by its revision, and they're disabled while the bug has unpublished edits, because that draft would later overwrite the new status.&lt;/p&gt;

&lt;p&gt;The Zombie Detector's brief set the rule the whole feature follows:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;GOAL: answer "Is this actually an old bug coming back?" with an EXPLAINABLE detector, not fake AI. The graveyard's history in the Content Lake is what catches regressions.&lt;/p&gt;

&lt;p&gt;… Only if the top score passes a threshold, use an Agent Action to write a one-sentence plain-English reason. … Never link automatically. Never claim "AI similarity %"; label it "match score from N signals".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here Claude Code pushed back on one detail. I'd asked it to compare against "buried/disturbed graves", but a disturbed grave has already risen, and a grave only rises once, so "Confirm resurrection" would have had to refuse those matches. It compares only graves that can still rise, and a disturbed grave's story continues at its zombie. I kept that too.&lt;/p&gt;
&lt;h3&gt;
  
  
  The one that didn't
&lt;/h3&gt;

&lt;p&gt;Before the polish pass, I asked Claude Code to look through the other projects on my Mac for bugs I'd really fixed, so that a few graves could be real. It found no bug-fix commits to use. So I dropped that and kept the classic bugs.&lt;/p&gt;
&lt;h3&gt;
  
  
  Where it got stuck, and how we course-corrected
&lt;/h3&gt;

&lt;p&gt;Most of these only showed up in a production build or on the live site, not in &lt;code&gt;npm run dev&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publishing didn't reach the site.&lt;/strong&gt; In a production build, publishing from the Studio didn't update the site, and an open tab stayed one change behind. There were two causes. The Studio route had no &lt;code&gt;&amp;lt;SanityLive /&amp;gt;&lt;/code&gt; to clear the cache, and in production next-sanity revalidates with the &lt;code&gt;'max'&lt;/code&gt; cache profile, which serves the stale page one more time. The fix: &lt;code&gt;&amp;lt;SanityLive /&amp;gt;&lt;/code&gt; in the Studio layout too, plus a custom action that calls &lt;code&gt;updateTag&lt;/code&gt;, which expires the cache immediately. &lt;code&gt;npm run dev&lt;/code&gt; hid both problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first Vercel deploy failed&lt;/strong&gt; with &lt;code&gt;No Output Directory named "dist" found&lt;/code&gt;. The Vercel project had been created with the "Other" framework preset. A &lt;code&gt;vercel.json&lt;/code&gt; that sets &lt;code&gt;"framework": "nextjs"&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scripts silently skipped drafts.&lt;/strong&gt; With this API version, a query that doesn't name a perspective gets &lt;code&gt;published&lt;/code&gt;, so a cleanup script would never have found &lt;code&gt;drafts.*&lt;/code&gt; documents. Scripts that need drafts now ask for &lt;code&gt;raw&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Report resurrection" flickered disabled.&lt;/strong&gt; Its "has this grave already risen?" check restarted every time the document changed, and it blocked the button while it ran. Now the button is only disabled once a zombie is known to exist, and it checks again when you confirm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The site's CSS leaked into the Studio.&lt;/strong&gt; The root 404 page imported the site's global CSS, and a root not-found page's styles load on every route, so Tailwind ended up inside &lt;code&gt;/studio&lt;/code&gt;. A scoped CSS module fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Haunted state didn't show up in local tests.&lt;/strong&gt; The coroner wrote its match while no browser tab was open, so the local production build's cache never heard about it. In production, Sanity's webhook refreshes the site; the test now sends the local site the same signed webhook (&lt;code&gt;encodeSignatureHeader&lt;/code&gt; from &lt;code&gt;@sanity/webhook&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The last Functions deploy "failed" after it had worked.&lt;/strong&gt; &lt;code&gt;npx sanity blueprints deploy&lt;/code&gt; ended with &lt;code&gt;OPERATION_UNCONFIRMED&lt;/code&gt; after losing its connection, but &lt;code&gt;blueprints info&lt;/code&gt; showed the operation &lt;code&gt;COMPLETED&lt;/code&gt; in 1 minute 5 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two traps we avoided on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The timezone bug could have had a timezone bug.&lt;/strong&gt; Sanity &lt;code&gt;date&lt;/code&gt; fields are plain strings like &lt;code&gt;"2026-03-29"&lt;/code&gt;, with no timezone. Formatting them in the visitor's timezone would make every bug die a day early for anyone west of Greenwich, including on the timezone bug's own tombstone. So every date is formatted in UTC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dot in a document ID makes it private.&lt;/strong&gt; Sanity treats a dotted ID as a private path (that's how &lt;code&gt;drafts.&lt;/code&gt; works), so a public dataset won't serve &lt;code&gt;language.typescript&lt;/code&gt; without a token. The seed used dashed IDs like &lt;code&gt;language-typescript&lt;/code&gt; from the start, and the report form's rate-limit counters use dotted IDs on purpose, so they stay private.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;rzjmw6lg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt; (public). Try it: &lt;a href="https://rzjmw6lg.api.sanity.io/v2026-09-28/data/query/production?query=*%5B_type%20%3D%3D%20%22bug%22%5D%20%7C%20order%28bornAt%20asc%29%20%7Bname%2C%20status%2C%20epitaph%7D" rel="noopener noreferrer"&gt;every bug, as JSON&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are three document types: &lt;code&gt;bug&lt;/code&gt;, &lt;code&gt;language&lt;/code&gt; and &lt;code&gt;causeOfDeath&lt;/code&gt;. Here's how everything reaches them:&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%2Flre75cu2ftri37j0p6p4.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%2Flre75cu2ftri37j0p6p4.png" alt="Architecture: Sanity Studio with custom actions, the Content Lake, two Sanity Functions (the gravedigger, and the coroner with Agent Actions and the Zombie Detector), the Morgue App SDK app, and the Next.js site with the Live Content API, a signed webhook and the report form, plus the Workflows experiment on a branch" width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The schema: zombies are documents, not checkboxes
&lt;/h3&gt;

&lt;p&gt;The central decision: &lt;strong&gt;a zombie is a whole new &lt;code&gt;bug&lt;/code&gt; document that points to its previous life.&lt;/strong&gt; It isn't a status flag on the old one.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sanity/schemaTypes/bug.ts (simplified)&lt;/span&gt;
&lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;list&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;suspected-dead&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;fix-merged&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;buried&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;zombie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;previousLife&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reference&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zombie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="nf"&gt;defineField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timesResurrected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every death keeps its own history.&lt;/strong&gt; A zombie gets fixed and buried again, so it needs its own dates and its own epitaph. That's also what the timeline walks through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zombies of zombies work for free.&lt;/strong&gt; A chain is just references pointing backwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Disturbed" and "Haunted" aren't stored anywhere.&lt;/strong&gt; GROQ works them out by asking who points at this grave:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*[_type == "bug"]{
  ...,
  "disturbed": count(*[_type == "bug" &amp;amp;&amp;amp; previousLife._ref == ^._id]) &amp;gt; 0,
  "haunted": count(*[_type == "bug" &amp;amp;&amp;amp; matchStatus == "candidate"
                     &amp;amp;&amp;amp; resurrectionCandidate._ref == ^._id]) &amp;gt; 0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The detector only added optional fields:&lt;/strong&gt; &lt;code&gt;component&lt;/code&gt;, &lt;code&gt;symptoms&lt;/code&gt;, &lt;code&gt;fixSummary&lt;/code&gt; and &lt;code&gt;fixUrl&lt;/code&gt; on every bug, plus the match fields on new ones. No existing document had to change shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Languages and causes of death are references&lt;/strong&gt;, so filters, the leaderboard and the detector compare them reliably. The whole leaderboard is &lt;strong&gt;one GROQ query&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix date, the burial date, the resurrection counter and the detector's results are read-only in the Studio.&lt;/strong&gt; There, only the actions and functions set them, so they record what actually happened rather than what someone typed. (&lt;code&gt;readOnly&lt;/code&gt; only applies to the Studio: the importer script sets historical dates from a file.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  How document actions work
&lt;/h3&gt;

&lt;p&gt;Every button at the bottom of a Sanity document (Publish, Delete…) is a &lt;em&gt;document action&lt;/em&gt;, and you can add your own. An action is a small React component: Sanity passes it the document's current state and it returns a button (label, disabled, tooltip, onHandle, optional confirm dialog), or &lt;code&gt;null&lt;/code&gt; to stay hidden. That's why "Confirm resurrection" only appears on a bug the detector flagged. Because an action is a component, it re-renders whenever the document changes, which is how "Can bury in 4 days" stays up to date without any extra code. Actions run in the browser as the signed-in editor, with their permissions, so there's no API token in the code.&lt;/p&gt;

&lt;p&gt;You register them in &lt;code&gt;sanity.config.ts&lt;/code&gt;. Mine go right after Publish, only on bugs, and Sanity's own actions stay:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sanity.config.ts&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;schemaType&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schemaType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;afterPublish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;publish&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;afterPublish&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;ConfirmResurrectionAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;DismissResurrectionAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;AcceptCoronerReportAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;ApprovePublicReportAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;lifecycleActions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;afterPublish&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="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Live everywhere
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sanityFetch&lt;/code&gt; plus &lt;code&gt;&amp;lt;SanityLive /&amp;gt;&lt;/code&gt; push Studio edits and function writes to open tabs in about 3 seconds. A signed Sanity &lt;strong&gt;webhook&lt;/strong&gt; refreshes the cached site when nobody has it open (about 6 seconds). That's how a grave turns haunted on its own a few seconds after a report.&lt;/p&gt;
&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;This is the Claude Code session behind this build, with secrets redacted. The embed shows the Phase 5 slice, where the lifecycle document actions were built and tested. The &lt;a href="https://dev.to/agent_sessions/building-bug-graveyard-nextjs-sanity-with-claude-code-5ivb1b"&gt;full session&lt;/a&gt; has every phase.&lt;/p&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Building Bug Graveyard (Next.js + Sanity) with Claude Code&lt;/span&gt;
      &lt;span class="agent-session-model-badge"&gt;claude-opus-5-5&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Phase 5: add custom Sanity document actions that model a bug's lifecycle, and fill in the&lt;br&gt;
read-only fields (fixMergedAt, buriedAt, timesResurrected) that nothing sets yet.&lt;/p&gt;

&lt;p&gt;Actions on the bug type (register them in sanity.config.ts via document.actions, keep the&lt;br&gt;
default Publish/Delete etc.):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"🩹 Mark fix merged": enabled only when status = suspected-dead.
Sets status = fix-merged and fixMergedAt = today, then publishes.&lt;/li&gt;
&lt;li&gt;"🪦 Declare buried": enabled only when status = fix-merged AND at least 7 days have passed
since fixMergedAt. When disabled, the label/title says how many days are left
(e.g. "Can bury in 4 days"). Sets status = buried and buriedAt = today, then publishes.&lt;/li&gt;
&lt;li&gt;"🧟 Report resurrection": enabled when status = fix-merged or buried. Creates a NEW bug
document: status = zombie, previousLife = this bug, timesResurrected = old + 1,
name = "&amp;lt;original name&amp;gt; (Zombie #n)", same language/cause, a new unique slug, bornAt = today.
Then open the new zombie in the Studio so I can write its epitaph.
Show a confirm dialog first: "Are you sure? This bug will rise from its grave."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rules:&lt;br&gt;
- Use dates in UTC like the rest of the app.&lt;br&gt;
- Handle drafts correctly (act on the published doc, don't leave stray drafts).&lt;br&gt;
- A zombie can later go through the same lifecycle (fix merged → buried → resurrect again),&lt;br&gt;
  so zombies of zombies work.&lt;br&gt;
- Make the 7-day rule a single constant so I can mention it in the write-up.&lt;/p&gt;

&lt;p&gt;Test it end to end with temporary test bugs (delete them after), confirm the homepage and&lt;br&gt;
grave pages update live, then run build/lint/type-check, add the Phase 5 entry to NOTES.md,&lt;br&gt;
commit and deploy. Also explain in plain words how document actions work in Sanity, for my&lt;br&gt;
write-up.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Phase 5 is Studio code, so I'll start with the document action API in the installed Sanity 5.31: the props an action receives, what it can return (label, disabled, title, confirm dialog), and how to register actions and open another document.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;f=$(grep -l "interface DocumentActionProps" node_modules/sanity/lib/*.d.ts no...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ f=$(grep -l "interface DocumentActionProps" node_modules/sanity/lib/*.d.ts node_modules/sanity/lib/_chunks-dts/*.d.ts 2&amp;gt;/dev/null | head -1); echo "defs in: $f"; for n in "interface DocumentActionProps" "interface DocumentActionDescription" "interface DocumentActionConfirmDialogProps" "interface DocumentActionDialogDialogProps" "type DocumentActionDialogProps" "interface DocumentActionComponent" "type DocumentActionsResolver" "interface DocumentActionsContext"; do echo "=================== $n"; awk -v pat="$n" 'index($0, pat) {p=1} p {print; if (/^}/ || (/;$/ &amp;amp;&amp;amp; pat ~ /^type/)) {exit}}' "$f" | grep -v "^\s*\*\|/\*\*" | head -30; done&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;defs in: node_modules/sanity/lib/_chunks-dts/ActiveWorkspaceMatcherContext.d.ts
=================== interface DocumentActionProps
interface DocumentActionProps extends EditStateFor {
  revision?: string;
  onComplete: () =&amp;gt; void;
  initialValueResolved: boolean;
}
=================== interface DocumentActionDescription
interface DocumentActionDescription extends GroupableActionDescription&amp;lt;DocumentActionGroup&amp;gt; {
  dialog?: DocumentActionDialogProps | false | null;
}
=================== interface DocumentActionConfirmDialogProps
interface DocumentActionConfirmDialogProps {
  type: 'confirm';
  tone?: ButtonTone;
  message: ReactNode;
  onConfirm: () =&amp;gt; void;
  onCancel: () =&amp;gt; void;
  cancelButtonIcon?: ComponentType | ReactNode;
  cancelButtonText?: string;
  confirmButtonIcon?: ComponentType | ReactNode;
  confirmButtonText?: string;
}
=================== interface DocumentActionDialogDialogProps
=================== type DocumentActionDialogProps
type DocumentActionDialogProps = DocumentActionConfirmDialogProps | DocumentActionPopoverDialogProps | DocumentActionModalDialogProps | DocumentActionCustomDialogComponentProps;
=================== interface DocumentActionComponent
interface DocumentActionComponent extends ActionComponent&amp;lt;DocumentActionProps, DocumentActionDescription&amp;gt; {
  action?: keyof DocumentActionKeys;
  displayName?: string;
}
=================== type DocumentActionsResolver
type DocumentActionsResolver = ComposableOption&amp;lt;DocumentActionComponent[], DocumentActionsContext&amp;gt;;
=================== interface DocumentActionsContext
interface DocumentActionsContext extends ConfigContext$1 {
  documentId?: string;
  schemaType: string;
  releaseId: string | undefined;
  versionType: DocumentActionsVersionType;
}&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;f=node_modules/sanity/lib/_chunks-dts/ActiveWorkspaceMatcherContext.d.ts; for...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ f=node_modules/sanity/lib/_chunks-dts/ActiveWorkspaceMatcherContext.d.ts; for n in "interface EditStateFor" "interface GroupableActionDescription" "interface DocumentActionModalDialogProps" "type DocumentActionsVersionType" "interface ActionComponent&amp;lt;"; do echo "=================== $n"; awk -v pat="$n" 'index($0, pat) {p=1} p {print; if (/^}/ || (/;$/ &amp;amp;&amp;amp; pat ~ /^type/)) {exit}}' "$f" | grep -v "^\s*\*\|/\*\*\|^\s*\*/" | head -26; done; echo "=== exports of interest"; for n in useClient useDocumentStore useDocumentOperation useCurrentUser getPublishedId getDraftId isDraftId useToast; do printf "%-22s %s\n" $n "$(grep -c "\b$n\b" node_modules/sanity/lib/index.d.ts)"; done; grep -o "navigateIntent[^;]*;" node_modules/sanity/lib/_chunks-dts/*.d.ts node_modules/sanity/lib/router.d.ts 2&amp;gt;/dev/null | head -2&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;=================== interface EditStateFor
interface EditStateFor {
  id: string;
  type: string;
  transactionSyncLock: TransactionSyncLockState | null;
  draft: SanityDocument | null;
  published: SanityDocument | null;
  version: SanityDocument | null;
  liveEdit: boolean;
  liveEditSchemaType: boolean;
  ready: boolean;
  release: string | undefined;
}
=================== interface GroupableActionDescription
interface GroupableActionDescription&amp;lt;GroupType = unknown&amp;gt; extends BaseActionDescription {
  group?: GroupType[];
}
=================== interface DocumentActionModalDialogProps
interface DocumentActionModalDialogProps {
  type?: 'dialog';
  content: ReactNode;
  footer?: DialogProps['footer'];
  header?: ReactNode;
  onClose: () =&amp;gt; void;
  showCloseButton?: boolean;
  width?: 'small' | 'medium' | 'large' | 'full';
}
=================== type DocumentActionsVersionType
type DocumentActionsVersionType = 'published' | 'draft' | 'revision' | 'version' | 'scheduled-draft';
=================== interface ActionComponent&amp;lt;
interface ActionComponent&amp;lt;ActionProps, ActionDescription&amp;gt; {
  (props: ActionProps): ActionDescription | null;
}
=== exports of interest
useClient              2
useDocumentStore       2
useDocumentOperation   2
useCurrentUser         2
getPublishedId         2
getDraftId             2
isDraftId              2
useToast               0
node_modules/sanity/lib/_chunks-dts/types3.d.ts:navigateIntent: (intentName: string, params?: IntentParameters, options?: NavigateBaseOptions) =&amp;gt; void;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;f=node_modules/sanity/lib/_chunks-dts/ActiveWorkspaceMatcherContext.d.ts; awk...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ f=node_modules/sanity/lib/_chunks-dts/ActiveWorkspaceMatcherContext.d.ts; awk 'index($0, "interface BaseActionDescription") {p=1} p {print; if (/^}/) exit}' $(grep -l "interface BaseActionDescription" node_modules/sanity/lib/_chunks-dts/*.d.ts | head -1) | grep -v "^\s*\*\|/\*\*\|^\s*\*/"; echo "=== listenQuery"; grep -h -o "listenQuery: ([^;]*;" node_modules/sanity/lib/_chunks-dts/*.d.ts | head -2; grep -h -B2 -A8 "interface ListenQueryOptions" node_modules/sanity/lib/_chunks-dts/*.d.ts | grep -v "^\s*\*\|/\*\*\|^\s*\*/" | head -14&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;interface BaseActionDescription {
  disabled?: boolean;
  icon?: ReactNode | ComponentType;
  label: string;
  onHandle?: () =&amp;gt; void;
  title?: ReactNode;
  tone?: ButtonTone;
  shortcut?: string | null;
  dialog?: unknown;
}
=== listenQuery
interface ListenQueryOptions {
  tag?: string;
  apiVersion?: string;
  perspective?: ClientPerspective;
  throttleTime?: number;
  transitions?: ('update' | 'appear' | 'disappear')[];
}
declare function listenQuery(client: SanityClient, query: string | {&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;grep -h -A6 "declare function listenQuery" node_modules/sanity/lib/_chunks-dt...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ grep -h -A6 "declare function listenQuery" node_modules/sanity/lib/_chunks-dts/*.d.ts | head -10; grep -c "listenQuery" node_modules/sanity/lib/index.d.ts; grep -oE "[A-Za-z_$0-9]+ as listenQuery\b" node_modules/sanity/lib/index.d.ts | head -2; grep -h -B3 -A12 "interface DocumentStore {" node_modules/sanity/lib/_chunks-dts/*.d.ts | grep -iE "listenQuery|interface DocumentStore" | head&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;declare function listenQuery(client: SanityClient, query: string | {
  fetch: string;
  listen: string;
}, params?: ListenQueryParams, options?: ListenQueryOptions): Observable&amp;lt;any&amp;gt;;
/**
 * @hidden
 * @beta */
2
ugrep: error: error at position 21
(?m)[A-Za-z_/bin/zsh-9]+ as listenQuery\b
                     \___invalid character class range


interface DocumentStore {
  listenQuery: (query: string | {&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;grep -rhoE "__studio_auth_token[^\"'\`]{0,40}" node_modules/sanity/lib/ 2&amp;gt;/de...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ grep -rhoE "__studio_auth_token[^\"'\`]{0,40}" node_modules/sanity/lib/ 2&amp;gt;/dev/null | sort -u | head -5; f=$(grep -rl "__studio_auth_token" node_modules/sanity/lib/_chunks-es/*.js 2&amp;gt;/dev/null | head -1); echo "file: $f"; grep -oE ".{200}__studio_auth_token.{300}" "$f" | head -2&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;__studio_auth_token_
file: 
ugrep: warning: : No such file or directory&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;f=$(grep -rl "__studio_auth_token_" node_modules/sanity/lib/ 2&amp;gt;/dev/null | he...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ f=$(grep -rl "__studio_auth_token_" node_modules/sanity/lib/ 2&amp;gt;/dev/null | head -3); echo "$f"; for x in $f; do grep -oE ".{0,250}__studio_auth_token_.{0,250}" "$x" | head -3; done | cut -c1-600&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;node_modules/sanity/lib/index.js
node_modules/sanity/lib/index.js.map
ugrep: warning: node_modules/sanity/lib/index.js
node_modules/sanity/lib/index.js.map: No such file or directory&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Still in Phase 5 prep. I've mapped the document action API and am checking how to sign in a headless Studio for tests. Next I'll write the lifecycle actions.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;grep -oE ".{0,200}__studio_auth_token_.{0,300}" node_modules/sanity/lib/index...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ grep -oE ".{0,200}__studio_auth_token_.{0,300}" node_modules/sanity/lib/index.js | head -3 | cut -c1-520&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;ugrep: error: error at position 98
_(?:[^\n\x80-\xbf][\x80-\xbf]*){0,300}
         exceeds complexity limits___/&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node -e '
const s = require("fs").readFileSync("node_modules/sanity/lib/index...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ node -e '
const s = require("fs").readFileSync("node_modules/sanity/lib/index.js", "utf8")
let i = -1, n = 0
while ((i = s.indexOf("__studio_auth_token_", i + 1)) !== -1 &amp;amp;&amp;amp; n++ &amp;lt; 3) console.log("---\n" + s.slice(Math.max(0, i - 260), i + 320))
'&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;---
025-02-19"
&lt;/span&gt;}, DEFAULT_STUDIO_CLIENT_HEADERS = {
  "x-sanity-app": `studio@${SANITY_VERSION}`
}, versionedClient = (client, apiVersion) =&amp;gt; apiVersion ? client.withConfig({
  apiVersion
}) : client, AUTH_API_VERSION = "v2026-05-04", AUTH_TOKEN_STORAGE_PREFIX = "__studio_auth_token_", COOKIE_AUTH_STATE_PREFIX = "__studio_auth_cookie_state_", UNAUTHENTICATED = {
  authenticated: !1
}, AUTHENTICATED = {
  authenticated: !0
}, AUTH_CLIENT_OPTIONS = {
  apiVersion: AUTH_API_VERSION,
  useCdn: !1,
  perspective: "raw",
  requestTagPrefix: "sanity.studio",
  allowReconfigure: !1,&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node -e '
const s = require("fs").readFileSync("node_modules/sanity/lib/index...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ node -e '
const s = require("fs").readFileSync("node_modules/sanity/lib/index.js", "utf8")
for (const needle of ["AUTH_TOKEN_STORAGE_PREFIX}", "AUTH_TOKEN_STORAGE_PREFIX +", "getToken(projectId", "function getStorageKey", "localStorage.getItem("]) {
  let i = -1, n = 0
  while ((i = s.indexOf(needle, i + 1)) !== -1 &amp;amp;&amp;amp; n++ &amp;lt; 2) console.log("--- [" + needle + "]\n" + s.slice(Math.max(0, i - 200), i + 260))
}
' | head -60&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;--- [AUTH_TOKEN_STORAGE_PREFIX}]
&lt;/span&gt;,
  useCdn: !1,
  perspective: "raw",
  requestTagPrefix: "sanity.studio",
  allowReconfigure: !1,
  headers: DEFAULT_STUDIO_CLIENT_HEADERS
};
&lt;span class="p"&gt;function getAuthTokenStorageKey(projectId) {
&lt;/span&gt;  return `${AUTH_TOKEN_STORAGE_PREFIX}${projectId}`;
}
&lt;span class="p"&gt;function getCookieAuthStateKey(projectId) {
&lt;/span&gt;  return `${COOKIE_AUTH_STATE_PREFIX}${projectId}`;
}
&lt;span class="p"&gt;function createLocalStorageStorage(key) {
&lt;/span&gt;  function load() {
    try {
      const value = localStorage.getItem(key);
&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;--- [localStorage.getItem(]
-"), localStorage.removeItem(key), !0);
&lt;/span&gt;  } catch {
    return !1;
  }
})(), LOCAL_STORAGE_PREFIX = "sanity-locale";
&lt;span class="p"&gt;function getPreferredLocale(projectId, sourceId) {
&lt;/span&gt;  return supportsLocalStorage ? localStorage.getItem(getItemKey$1(projectId, sourceId)) ?? void 0 : void 0;
}
&lt;span class="p"&gt;function storePreferredLocale(projectId, sourceId, locale) {
&lt;/span&gt;  supportsLocalStorage &amp;amp;&amp;amp; localStorage.setItem(getItemKey$1(projectId, sourceId), locale);
}
&lt;span class="p"&gt;function getItemKey$1(projec
&lt;/span&gt;&lt;span class="gd"&gt;--- [localStorage.getItem(]
&lt;/span&gt;&lt;span class="p"&gt;ctId}`;
&lt;/span&gt;}
&lt;span class="p"&gt;function getCookieAuthStateKey(projectId) {
&lt;/span&gt;  return `${COOKIE_AUTH_STATE_PREFIX}${projectId}`;
}
&lt;span class="p"&gt;function createLocalStorageStorage(key) {
&lt;/span&gt;  function load() {
    try {
      const value = localStorage.getItem(key);
      return value === null ? void 0 : JSON.parse(value);
    } catch (err) {
      console.error(`Failed to parse localstorage value: ${err.message}`);
      return;
    }
  }
  function store(value) {
    try {
      value === void&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node -e '
const s = require("fs").readFileSync("node_modules/sanity/lib/index...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ node -e '
const s = require("fs").readFileSync("node_modules/sanity/lib/index.js", "utf8")
for (const needle of ["getAuthTokenStorageKey(", "loginMethod"]) {
  let i = s.indexOf("function getAuthTokenStorageKey") + 50, n = 0
  i = -1
  while ((i = s.indexOf(needle, i + 1)) !== -1 &amp;amp;&amp;amp; n++ &amp;lt; 4) console.log("--- [" + needle + "]\n" + s.slice(Math.max(0, i - 150), i + 250).replace(/\n\s+/g, " "))
}
' | head -40&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;--- [getAuthTokenStorageKey(]
&lt;/span&gt;  useCdn: !1, perspective: "raw", requestTagPrefix: "sanity.studio", allowReconfigure: !1, headers: DEFAULT_STUDIO_CLIENT_HEADERS
};
&lt;span class="p"&gt;function getAuthTokenStorageKey(projectId) { return `${AUTH_TOKEN_STORAGE_PREFIX}${projectId}`;
&lt;/span&gt;}
&lt;span class="p"&gt;function getCookieAuthStateKey(projectId) { return `${COOKIE_AUTH_STATE_PREFIX}${projectId}`;
&lt;/span&gt;}
&lt;span class="p"&gt;function createLocalStorageStorage(key) { function load() {
&lt;/span&gt;&lt;span class="gd"&gt;--- [getAuthTokenStorageKey(]
&lt;/span&gt;", getSessionId: getSessionId2, consumeHashToken: consumeHashToken2, ...providerOptions
}) { const tokenStorage = createBroadcastStorage( getAuthTokenStorageKey(projectId), // sets the initial value (currentTokenValue) =&amp;gt; { const hashToken = consumeHashToken2(); return hashToken ? { token: hashToken, authenticated: !0 } : currentTokenValue; 
&lt;span class="gd"&gt;--- [getAuthTokenStorageKey(]
&lt;/span&gt;: "\u2022" }) ] }, link.title);
}
&lt;span class="p"&gt;function getStoredToken(projectId) { if (supportsLocalStorage) try { const raw = localStorage.getItem(getAuthTokenStorageKey(projectId)); return raw ? JSON.parse(raw)?.token : void 0; } catch { return; }
&lt;/span&gt;}
&lt;span class="p"&gt;function resolveApiHost(apiHost) { if (apiHost) return apiHost; if (isStaging) return "https://api.sanity.work";
&lt;/span&gt;}
&lt;span class="p"&gt;async f
&lt;/span&gt;&lt;span class="gd"&gt;--- [getAuthTokenStorageKey(]
&lt;/span&gt;.token ? { token, ignoreBrowserTokenWarning: !0 } : { withCredentials: !0 } }, client = factory(clientConfig), tokenKey = getAuthTokenStorageKey(input.projectId), cookieKey = getCookieAuthStateKey(input.projectId), observable$ = using(() =&amp;gt; { const cookieState = token ? null : createBroadcastState(cookieKey); return { cookieState, unsubscribe: () =&amp;gt; 
&lt;span class="gd"&gt;--- [loginMethod]
&lt;/span&gt;&lt;span class="p"&gt;filter((official) =&amp;gt; customProviders.some((provider) =&amp;gt; provider.url !== official.url)).concat(customProviders);
&lt;/span&gt;}
&lt;span class="p"&gt;function createHrefForProvider({ loginMethod = "dual", projectId, url, redirectPath
&lt;/span&gt;}) { const params = new URLSearchParams(); return params.set("origin", `${window.location.origin}${redirectPath}`), params.set("projectId", projectId), loginMethod === "token" ? params.set(
&lt;span class="gd"&gt;--- [loginMethod]
&lt;/span&gt; const params = new URLSearchParams(); return params.set("origin", `${window.location.origin}${redirectPath}`), params.set("projectId", projectId), loginMethod === "token" ? params.set("withSid", "true") : params.set("type", loginMethod), `${url}?${params}`;
}
&lt;span class="p"&gt;function createLoginComponent({ client$, loginMethod, redirectOnSingle, wasLogout, isHandlingCallback, ...providerOptions
&lt;/span&gt;}) 
&lt;span class="gd"&gt;--- [loginMethod]
&lt;/span&gt;&lt;span class="p"&gt;w.location.origin}${redirectPath}`), params.set("projectId", projectId), loginMethod === "token" ? params.set("withSid", "true") : params.set("type", loginMethod), `${url}?${params}`;
&lt;/span&gt;}
&lt;span class="p"&gt;function createLoginComponent({ client$, loginMethod, redirectOnSingle, wasLogout, isHandlingCallback, ...providerOptions
&lt;/span&gt;}) { function LoginComponent(t0) { const $ = c(28), { projectId, 
&lt;span class="gd"&gt;--- [loginMethod]
&lt;/span&gt;&lt;span class="p"&gt;hod === "token" ? params.set("withSid", "true") : params.set("type", loginMethod), `${url}?${params}`;
&lt;/span&gt;}
&lt;span class="p"&gt;function createLoginComponent({ client$, loginMethod, redirectOnSingle, wasLogout, isHandlingCallback, ...providerOptions&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/Users/jashanpreetkaur/Desktop/bug-graveyard/sanity/lib/lifecycle.ts&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;jashanpreetkaur&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Desktop&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;bug&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;graveyard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sanity&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;File&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="nx"&gt;successfully&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;jashanpreetkaur&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Desktop&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;bug&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;graveyard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sanity&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ts &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;no&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;Read&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="nx"&gt;back&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Before building on these helpers I'll check them quickly with Node's built-in TypeScript support, including a late-evening IST time that is still the previous day in UTC.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;node --input-type=module -e '
import {BURIAL_WAIT_DAYS, daysUntilBurial, zomb...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ node --input-type=module -e '
import {BURIAL_WAIT_DAYS, daysUntilBurial, zombieName, slugify, todayUTC, daysBetween} from "./sanity/lib/lifecycle.ts"
const eq = (label, got, want) =&amp;gt; console.log(`${JSON.stringify(got) === JSON.stringify(want) ? "ok  " : "FAIL"} ${label}: ${JSON.stringify(got)}`)
eq("BURIAL_WAIT_DAYS", BURIAL_WAIT_DAYS, 7)
eq("fixed today -&amp;gt; 7 days left", daysUntilBurial("2026-09-29", "2026-09-29"), 7)
eq("fixed 3 days ago -&amp;gt; 4 left", daysUntilBurial("2026-09-26", "2026-09-29"), 4)
eq("fixed 7 days ago -&amp;gt; 0 left", daysUntilBurial("2026-09-22", "2026-09-29"), 0)
eq("fixed 30 days ago -&amp;gt; 0 left", daysUntilBurial("2026-08-30", "2026-09-29"), 0)
eq("across a month end", daysBetween("2026-02-27", "2026-03-02"), 3)
eq("01:30 IST on the 29th is still the 28th in UTC", todayUTC(new Date("2026-09-29T01:30:00+05:30")), "2026-09-28")
eq("first zombie", zombieName("Timezone bug in scheduler", 1), "Timezone bug in scheduler (Zombie #1)")
eq("zombie of a zombie keeps the original name", zombieName("Timezone bug in scheduler (Zombie #1)", 2), "Timezone bug in scheduler (Zombie #2)")
eq("slug", slugify("Timezone bug in scheduler (Zombie #2)"), "timezone-bug-in-scheduler-zombie-2")
eq("slug with accents and symbols", slugify("  Crème brûlée: C++ bug!! "), "creme-brulee-c-bug")
' 2&amp;gt;&amp;amp;1 | grep -v ExperimentalWarning&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;(node:24361) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///Users/jashanpreetkaur/Desktop/bug-graveyard/sanity/lib/lifecycle.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /Users/jashanpreetkaur/Desktop/bug-graveyard/package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
ok   BURIAL_WAIT_DAYS: 7
ok   fixed today -&amp;gt; 7 days left: 7
ok   fixed 3 days ago -&amp;gt; 4 left: 4
ok   fixed 7 days ago -&amp;gt; 0 left: 0
ok   fixed 30 days ago -&amp;gt; 0 left: 0
ok   across a month end: 3
ok   01:30 IST on the 29th is still the 28th in UTC: "2026-09-28"
ok   first zombie: "Timezone bug in scheduler (Zombie #1)"
ok   zombie of a zombie keeps the original name: "Timezone bug in scheduler (Zombie #2)"
ok   slug: "timezone-bug-in-scheduler-zombie-2"
ok   slug with accents and symbols: "creme-brulee-c-bug"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/Users/jashanpreetkaur/Desktop/bug-graveyard/sanity/actions/lifecycle.tsx&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight tsx"&gt;&lt;code&gt;&lt;span class="sr"&gt;/Users/&lt;/span&gt;&lt;span class="nx"&gt;jashanpreetkaur&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Desktop&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;bug&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;graveyard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sanity&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tsx&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;File&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="nx"&gt;successfully&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;jashanpreetkaur&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Desktop&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;bug&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;graveyard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sanity&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tsx &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;no&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;Read&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="nx"&gt;back&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/Users/jashanpreetkaur/Desktop/bug-graveyard/sanity.config.ts&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;jashanpreetkaur&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Desktop&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;bug&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;graveyard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sanity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight typescript"&gt;&lt;code&gt;&lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="mi"&gt;2&lt;/span&gt;   
&lt;span class="mi"&gt;3&lt;/span&gt;   &lt;span class="cm"&gt;/**
4    * This configuration is used to for the Sanity Studio that’s mounted on the `/app/studio/[[...tool]]/page.tsx` route
5    */&lt;/span&gt;
&lt;span class="mi"&gt;6&lt;/span&gt;   
&lt;span class="mi"&gt;7&lt;/span&gt;   &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visionTool&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@sanity/vision&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="mi"&gt;8&lt;/span&gt;   &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sanity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="mi"&gt;9&lt;/span&gt;   &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;structureTool&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sanity/structure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="mi"&gt;10&lt;/span&gt;  
&lt;span class="mi"&gt;11&lt;/span&gt;  &lt;span class="c1"&gt;// Go to https://www.sanity.io/docs/api-versioning to learn how API versioning works&lt;/span&gt;
&lt;span class="mi"&gt;12&lt;/span&gt;  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./sanity/env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="mi"&gt;13&lt;/span&gt;  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./sanity/schemaTypes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="mi"&gt;14&lt;/span&gt;  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;structure&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./sanity/structure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="mi"&gt;15&lt;/span&gt;  
&lt;span class="mi"&gt;16&lt;/span&gt;  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;span class="mi"&gt;17&lt;/span&gt;    &lt;span class="na"&gt;basePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/studio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="mi"&gt;18&lt;/span&gt;    &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="mi"&gt;19&lt;/span&gt;    &lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="mi"&gt;20&lt;/span&gt;    &lt;span class="c1"&gt;// Add and edit the content schema in the './sanity/schemaTypes' folder&lt;/span&gt;
&lt;span class="mi"&gt;21&lt;/span&gt;    &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="mi"&gt;22&lt;/span&gt;    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="mi"&gt;23&lt;/span&gt;      &lt;span class="nf"&gt;structureTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="nx"&gt;structure&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="mi"&gt;24&lt;/span&gt;      &lt;span class="c1"&gt;// Vision is for querying with GROQ from inside the Studio&lt;/span&gt;
&lt;span class="mi"&gt;25&lt;/span&gt;      &lt;span class="c1"&gt;// https://www.sanity.io/docs/the-vision-plugin&lt;/span&gt;
&lt;span class="mi"&gt;26&lt;/span&gt;      &lt;span class="nf"&gt;visionTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;defaultApiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="mi"&gt;27&lt;/span&gt;    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="mi"&gt;28&lt;/span&gt;  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="mi"&gt;29&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-edit"&gt;Edit&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/Users/jashanpreetkaur/Desktop/bug-graveyard/sanity.config.ts&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;jashanpreetkaur&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Desktop&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;bug&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;graveyard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sanity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;The&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;jashanpreetkaur&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Desktop&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;bug&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;graveyard&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sanity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="nx"&gt;has&lt;/span&gt; &lt;span class="nx"&gt;been&lt;/span&gt; &lt;span class="nx"&gt;updated&lt;/span&gt; &lt;span class="nx"&gt;successfully&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;no&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;Read&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="nx"&gt;back&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;npx tsc --noEmit 2&amp;gt;&amp;amp;1 | head -20 &amp;amp;&amp;amp; echo "tsc exit: ${pipestatus[1]}"; npm ru...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ npx tsc --noEmit 2&amp;gt;&amp;amp;1 | head -20 &amp;amp;&amp;amp; echo "tsc exit: ${pipestatus[1]}"; npm run lint --silent 2&amp;gt;&amp;amp;1 | tail -20; echo "lint exit: ${pipestatus[1]}"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tsc exit: 0
lint exit: 0&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Type-check and lint are clean. Next I'll check Sanity's own schema validation, whether TypeGen still runs now that &lt;code&gt;sanity/&lt;/code&gt; contains Studio-only code, and the production build.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;npx sanity schemas validate 2&amp;gt;&amp;amp;1 | grep -E "Errors|Warnings|✖|rror" | head -5...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ npx sanity schemas validate 2&amp;gt;&amp;amp;1 | grep -E "Errors|Warnings|✖|rror" | head -5; npm run typegen 2&amp;gt;&amp;amp;1 | grep -E "✔ Success|queries and|rror|✖" ; git diff --stat sanity/types.ts sanity/extract.json; npm run build 2&amp;gt;&amp;amp;1 | grep -E "Compiled|TypeScript|rror|/studio"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;✖ Errors:   0 errors
⚠ Warnings: 0 warnings
✔ Successfully generated types to /Users/jashanpreetkaur/Desktop/bug-graveyard/sanity/types.ts in 382ms
  └─ 4 queries and 17 schema types
✓ Compiled successfully in 944ms
  Running TypeScript ...
  Finished TypeScript in 1112ms ...
└ ○ /studio/[[...tool]]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad
node --input-type=module -e '
import {writeFileSync} from "node:fs"
const day = (n) =&amp;gt; new Date(Date.now() - n * 864e5).toISOString().slice(0, 10)
const ref = (_ref) =&amp;gt; ({_type: "reference", _ref})
const bug = (key, extra) =&amp;gt; ({
  _id: `test-bug-life-${key}`, _type: "bug", name: `Lifecycle test: ${key}`,
  slug: {_type: "slug", current: `test-bug-life-${key}`},
  language: ref("language-typescript"), causeOfDeath: ref("causeOfDeath-off-by-one"),
  severity: "low", bornAt: day(30), timesResurrected: 0, epitaph: `Temporary: ${key}.`, ...extra,
})
const docs = [
  bug("suspected", {status: "suspected-dead"}),
  bug("waiting", {status: "fix-merged", fixMergedAt: day(3)}),
  bug("ready", {status: "fix-merged", fixMergedAt: day(8)}),
  bug("buried", {status: "buried", fixMergedAt: day(20), buriedAt: day(12)}),
  bug("drafted", {status: "suspected-dead"}),
  {...bug("drafted", {status: "suspected-dead"}), _id: "drafts.test-bug-life-drafted", epitaph: "An unpublished edit."},
]
writeFileSync(process.argv[1], JSON.stringify(docs, null, 2))
' "$SP/life-docs.json"
npx sanity documents create "$SP/life-docs.json" --replace 2&amp;gt;&amp;amp;1 | grep -v "npm warn" | tail -8&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Upserted:
  - drafts.test-bug-life-drafted
  - test-bug-life-suspected
  - test-bug-life-waiting
  - test-bug-life-ready
  - test-bug-life-buried
  - test-bug-life-drafted&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/studio-cdp.mjs&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;File created successfully at: /private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/studio-cdp.mjs (file state is current in your context — no need to Read it back)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad
cat &amp;gt; "$SP/explore.mjs" &amp;lt;&amp;lt;'EOF'
import {openStudio, wait} from './studio-cdp.mjs'
const [, , port, base, sp] = process.argv
const s = await openStudio(port, base, 'rzjmw6lg')
await s.navigate('/studio/intent/edit/id=test-bug-life-suspected;type=bug')
await s.until('document form', `!!document.querySelector('[data-testid="action-menu-button"]')`, 60000)
await wait(2500)
console.log('url:', await s.url())
console.log('footer buttons:', JSON.stringify(await s.evaluate(`[...document.querySelectorAll('[data-testid="pane-footer"] button, [data-testid^="action-"]')].map(b =&amp;gt; ({testid: b.dataset.testid, text: b.innerText.trim().slice(0,40), disabled: b.disabled || b.getAttribute('aria-disabled') === 'true' || b.hasAttribute('data-disabled')}))`), null, 1))
await s.evaluate(`document.querySelector('[data-testid="action-menu-button"]').click()`)
await wait(800)
console.log('menu items:', JSON.stringify(await s.evaluate(`[...document.querySelectorAll('[role="menuitem"]')].map(b =&amp;gt; ({testid: b.dataset.testid, text: b.innerText.trim().slice(0,50), disabled: b.disabled || b.getAttribute('aria-disabled') === 'true' || b.hasAttribute('data-disabled')}))`), null, 1))
await s.shot(`${sp}/explore-menu.png`)
console.log('errors:', s.logs.slice(0, 5))
s.close()
EOF
npx next start -p 3333 &amp;gt; "$SP/next-3333.log" 2&amp;gt;&amp;amp;1 &amp;amp; SERVER=$!
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --no-first-run --remote-debugging-port=9341 --user-data-dir="$SP/chrome-p5" about:blank &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp; CHROME=$!
curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://localhost:3333/ &amp;amp;&amp;amp; curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://127.0.0.1:9341/json/version
(cd "$SP" &amp;amp;&amp;amp; node explore.mjs 9341 http://localhost:3333 "$SP")
kill $CHROME $SERVER 2&amp;gt;/dev/null; wait $CHROME $SERVER 2&amp;gt;/dev/null; pkill -f "scratchpad/chrome-p5"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Exit code 1
url: /studio/structure/bug;test-bug-life-suspected
footer buttons: [
 {
  "testid": "action-intent-button",
  "text": "",
  "disabled": true
 },
 {
  "text": "",
  "disabled": true
 },
 {
  "text": "",
  "disabled": true
 },
 {
  "testid": "action-publish",
  "text": "Publish",
  "disabled": true
 },
 {
  "testid": "action-menu-button",
  "text": "",
  "disabled": true
 }
]
menu items: [
 {
  "testid": "action-🩹Mark fix merged",
  "text": "🩹 Mark fix merged",
  "disabled": false
 },
 {
  "testid": "action-🪦Declare buried",
  "text": "🪦 Declare buried",
  "disabled": true
 },
 {
  "testid": "action-🧟Report resurrection",
  "text": "🧟 Report resurrection",
  "disabled": true
 },
 {
  "testid": "action-Duplicate",
  "text": "Duplicate",
  "disabled": false
 },
 {
  "testid": "action-Delete",
  "text": "Delete\nCtrl\nOption\nD",
  "disabled": false
 }
]
errors: []&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-read"&gt;Read&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/explore-menu.png&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-write"&gt;Write&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/lifecycle-e2e.mjs&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;File created successfully at: /private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/lifecycle-e2e.mjs (file state is current in your context — no need to Read it back)&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad
npx next start -p 3333 &amp;gt; "$SP/next-3333.log" 2&amp;gt;&amp;amp;1 &amp;amp; SERVER=$!
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --no-first-run --remote-debugging-port=9342 --user-data-dir="$SP/chrome-p5b" about:blank &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp; CHROME=$!
curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://localhost:3333/ &amp;amp;&amp;amp; curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://127.0.0.1:9342/json/version
(cd "$SP" &amp;amp;&amp;amp; node lifecycle-e2e.mjs 9342 http://localhost:3333) 2&amp;gt;&amp;amp;1 | tee "$SP/e2e.log"
kill $CHROME $SERVER 2&amp;gt;/dev/null; wait $CHROME $SERVER 2&amp;gt;/dev/null; pkill -f "scratchpad/chrome-p5b"; true&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;1) Suspected dead → Mark fix merged
  ok   only "Mark fix merged" is enabled (✓ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection)
  ok   published: status fix-merged, fixMergedAt today (UTC) (1.4s)
  ok   no draft left behind
  FAIL now waiting 7 days to bury, resurrection allowed (✗ 🩹 Mark fix merged | ✗ 🪦 Can bury in 7 days | ✗ 🧟 Report resurrection)
  ok   grave page shows the fix date (0.7s)
2) Fixed 3 days ago
  ok   "Can bury in 4 days", disabled (✗ 🩹 Mark fix merged | ✗ 🪦 Can bury in 4 days | ✓ 🧟 Report resurrection)
3) Fixed 8 days ago → Declare buried
  ok   "Declare buried" is enabled (✗ 🩹 Mark fix merged | ✓ 🪦 Declare buried | ✓ 🧟 Report resurrection)
  ok   published: status buried, buriedAt today (UTC) (1.3s)
  ok   no draft left behind
  ok   grave page shows the burial date (0.7s)
4) Cancelling a resurrection changes nothing
file:///private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/studio-cdp.mjs:46
    throw new Error(`timed out waiting for: ${label}`)
          ^

Error: timed out waiting for: confirm dialog
    at Object.until (file:///private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/studio-cdp.mjs:46:11)
    at async file:///private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/lifecycle-e2e.mjs:111:1

Node.js v24.14.0&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad
cat &amp;gt; "$SP/debug-risen.mjs" &amp;lt;&amp;lt;'EOF'
import {openStudio, wait} from './studio-cdp.mjs'
const [, , port, base] = process.argv
const s = await openStudio(port, base, 'rzjmw6lg')
async function state(label) {
  await s.evaluate(`document.querySelector('[data-testid="action-menu-button"]').click()`)
  await s.until('menu', `document.querySelectorAll('[role="menuitem"]').length &amp;gt; 0`)
  const rect = await s.evaluate(`(() =&amp;gt; { const b = [...document.querySelectorAll('[role="menuitem"]')].find(b =&amp;gt; b.innerText.includes('resurrection')); const r = b.getBoundingClientRect(); return {x: r.x + r.width / 2, y: r.y + r.height / 2, disabled: b.getAttribute('aria-disabled') ?? String(b.disabled) } })()`)
  await s.send('Input.dispatchMouseEvent', {type: 'mouseMoved', x: rect.x, y: rect.y})
  await wait(900)
  const tip = await s.evaluate(`[...document.querySelectorAll('[data-ui="Popover"], [role="tooltip"], [data-testid*="tooltip"]')].map(e =&amp;gt; e.innerText.trim()).filter(Boolean).join(' || ')`)
  console.log(`${label}: disabled=${rect.disabled} tooltip="${tip}"`)
  await s.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}))`)
  await wait(300)
}
// "ready" was buried in place during the last run; open it fresh now
await s.navigate('/studio/intent/edit/id=test-bug-life-ready;type=bug')
await s.until('doc', `location.pathname.endsWith('test-bug-life-ready') &amp;amp;&amp;amp; !!document.querySelector('[data-testid="action-menu-button"]')`, 60000)
await wait(2000)
await state('fresh load of a buried bug')
for (const t of [3, 8]) { await wait(t * 1000); await state(`after ${t}s more`) }
s.close()
EOF
npx next start -p 3333 &amp;gt; "$SP/next-3333.log" 2&amp;gt;&amp;amp;1 &amp;amp; SERVER=$!
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --no-first-run --remote-debugging-port=9343 --user-data-dir="$SP/chrome-p5c" about:blank &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp; CHROME=$!
curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://localhost:3333/ &amp;amp;&amp;amp; curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://127.0.0.1:9343/json/version
(cd "$SP" &amp;amp;&amp;amp; node debug-risen.mjs 9343 http://localhost:3333)
kill $CHROME $SERVER 2&amp;gt;/dev/null; wait $CHROME $SERVER 2&amp;gt;/dev/null; pkill -f "scratchpad/chrome-p5c"; true&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;fresh load of a buried bug: disabled=false tooltip="What's new
Content Agent is generally available"
after 3s more: disabled=false tooltip="What's new
Content Agent is generally available"
after 8s more: disabled=false tooltip="What's new
Content Agent is generally available"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;python3 - &amp;lt;&amp;lt;'EOF'
import pathlib
p = pathlib.Path('sanity/actions/lifecycle.t...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ python3 - &amp;lt;&amp;lt;'EOF'
import pathlib
p = pathlib.Path('sanity/actions/lifecycle.tsx')
s = p.read_text()
reps = [
("""  const risen = useRisenZombie(bug?._id)

  function resurrect() {
    setConfirming(false)
    run(async () =&amp;gt; {
      if (!bug) return
      const number""",
"""  const risen = useRisenZombie(bug?._id)

  function resurrect() {
    setConfirming(false)
    run(async () =&amp;gt; {
      if (!bug) return
      // Checked again here: the live check can lag, or a zombie may just have risen elsewhere.
      const already = await client.fetch&amp;lt;string | null&amp;gt;(RISEN_QUERY, {id: bug._id}, {perspective: 'raw'})
      if (already) throw new Error(`It already rose as “${already}”. Report the next resurrection on that grave.`)
      const number"""),
("""    disabled: busy || Boolean(blocked) || !dead || risen !== null,""",
 """    disabled: busy || Boolean(blocked) || !dead || Boolean(risen),"""),
("""        : risen
          ? `It already rose as “${risen}”. Report the next resurrection on that grave.`
          : risen === undefined
            ? 'Checking whether it has already risen…'
            : 'Creates a zombie of this bug and opens it'),""",
 """        : risen
          ? `It already rose as “${risen}”. Report the next resurrection on that grave.`
          : 'Creates a zombie of this bug and opens it'),"""),
("""// The name of the zombie that already rose from this grave: null if none has,
// undefined while still checking. A grave only rises once; its zombie carries on
// the chain, so the next resurrection is reported on the zombie's grave.
function useRisenZombie(publishedId: string | undefined) {
  const documentStore = useDocumentStore()
  const [risen, setRisen] = useState&amp;lt;string | null | undefined&amp;gt;(undefined)

  useEffect(() =&amp;gt; {
    if (!publishedId) return
    const subscription = documentStore
      .listenQuery(
        '*[_type == "bug" &amp;amp;&amp;amp; previousLife._ref == $id][0].name',
        {id: publishedId},
        {perspective: 'published'},
      )""",
"""// A grave only rises once: its zombie carries on the chain, so the next
// resurrection is reported on the zombie's grave.
const RISEN_QUERY = '*[_type == "bug" &amp;amp;&amp;amp; previousLife._ref == $id][0].name'

// The name of the zombie that already rose from this grave, kept up to date;
// null if none has (or while the first check is still running).
function useRisenZombie(publishedId: string | undefined) {
  const documentStore = useDocumentStore()
  const [risen, setRisen] = useState&amp;lt;string | null&amp;gt;(null)

  useEffect(() =&amp;gt; {
    if (!publishedId) return
    const subscription = documentStore
      .listenQuery(RISEN_QUERY, {id: publishedId}, {perspective: 'published'})"""),
]
for a, b in reps:
    assert a in s, a[:80]
    s = s.replace(a, b)
p.write_text(s)
print("patched")
EOF
npx tsc --noEmit &amp;amp;&amp;amp; echo "tsc ok"; npm run lint --silent &amp;amp;&amp;amp; echo "lint ok"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;patched
tsc ok
lint ok&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad
python3 - "$SP/lifecycle-e2e.mjs" &amp;lt;&amp;lt;'EOF'
import sys, pathlib
p = pathlib.Path(sys.argv[1]); s = p.read_text()
a = """await open('test-bug-life-buried')
items = await menu()
check('the old grave cannot rise twice', items.find((i) =&amp;gt; i.text.includes('resurrection'))?.disabled === true, describe(items))"""
b = """await open('test-bug-life-buried')
await pollUntil('the old grave cannot rise twice', async () =&amp;gt; {
  const items = await menu()
  return items.find((i) =&amp;gt; i.text.includes('resurrection'))?.disabled === true || describe(items)
}, 15000)"""
assert a in s
p.write_text(s.replace(a, b))
EOF
echo "zombies of test bugs before reset: $(npx sanity documents query --api-version 2026-09-28 'count(*[_type == "bug" &amp;amp;&amp;amp; previousLife._ref in ["test-bug-life-suspected","test-bug-life-waiting","test-bug-life-ready","test-bug-life-buried","test-bug-life-drafted"]])' 2&amp;gt;&amp;amp;1 | grep -v 'npm warn' | tail -1)"
npx sanity documents create "$SP/life-docs.json" --replace 2&amp;gt;&amp;amp;1 | grep -v "npm warn" | head -1
npm run build 2&amp;gt;&amp;amp;1 | grep -E "Compiled|rror"
npx next start -p 3333 &amp;gt; "$SP/next-3333.log" 2&amp;gt;&amp;amp;1 &amp;amp; SERVER=$!
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --no-first-run --remote-debugging-port=9344 --user-data-dir="$SP/chrome-p5d" about:blank &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp; CHROME=$!
curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://localhost:3333/ &amp;amp;&amp;amp; curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://127.0.0.1:9344/json/version
(cd "$SP" &amp;amp;&amp;amp; node lifecycle-e2e.mjs 9344 http://localhost:3333) 2&amp;gt;&amp;amp;1 | tee "$SP/e2e.log"
kill $CHROME $SERVER 2&amp;gt;/dev/null; wait $CHROME $SERVER 2&amp;gt;/dev/null; pkill -f "scratchpad/chrome-p5d"; true&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;zombies of test bugs before reset:  ›   test-bug-life-buried","test-bug-life-drafted"]])
Upserted:
✓ Compiled successfully in 799ms
1) Suspected dead → Mark fix merged
  ok   only "Mark fix merged" is enabled (✓ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection)
  ok   published: status fix-merged, fixMergedAt today (UTC) (1.3s)
  ok   no draft left behind
  ok   now waiting 7 days to bury, resurrection allowed (✗ 🩹 Mark fix merged | ✗ 🪦 Can bury in 7 days | ✓ 🧟 Report resurrection)
  ok   grave page shows the fix date (0.9s)
2) Fixed 3 days ago
  ok   "Can bury in 4 days", disabled (✗ 🩹 Mark fix merged | ✗ 🪦 Can bury in 4 days | ✓ 🧟 Report resurrection)
3) Fixed 8 days ago → Declare buried
  ok   "Declare buried" is enabled (✗ 🩹 Mark fix merged | ✓ 🪦 Declare buried | ✓ 🧟 Report resurrection)
  ok   published: status buried, buriedAt today (UTC) (1.5s)
  ok   no draft left behind
  ok   grave page shows the burial date (0.7s)
4) Cancelling a resurrection changes nothing
  ok   confirm dialog shows the message
  ok   clicked Cancel
  ok   no zombie was created
5) Unpublished edits block the actions
  ok   all three disabled (✗ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection)
6) Buried → Report resurrection → Zombie #1
file:///private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/studio-cdp.mjs:46
    throw new Error(`timed out waiting for: ${label}`)
          ^

Error: timed out waiting for: document test-bug-life-buried
    at Object.until (file:///private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/studio-cdp.mjs:46:11)
    at async open (file:///private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/lifecycle-e2e.mjs:53:3)
    at async file:///private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad/lifecycle-e2e.mjs:127:1

Node.js v24.14.0&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad
python3 - "$SP/lifecycle-e2e.mjs" &amp;lt;&amp;lt;'EOF'
import sys, pathlib
p = pathlib.Path(sys.argv[1]); s = p.read_text()
a = "await graveTab.evaluate('window.__noReload = true')\nawait open('test-bug-life-buried')"
b = "await graveTab.evaluate('window.__noReload = true')\nawait studio.send('Page.bringToFront') // background tabs are throttled\nawait open('test-bug-life-buried')"
assert a in s
p.write_text(s.replace(a, b))
EOF
npx sanity documents create "$SP/life-docs.json" --replace 2&amp;gt;&amp;amp;1 | grep -v "npm warn" | head -1
npx next start -p 3333 &amp;gt; "$SP/next-3333.log" 2&amp;gt;&amp;amp;1 &amp;amp; SERVER=$!
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --no-first-run --remote-debugging-port=9345 --user-data-dir="$SP/chrome-p5e" about:blank &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp; CHROME=$!
curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://localhost:3333/ &amp;amp;&amp;amp; curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://127.0.0.1:9345/json/version
(cd "$SP" &amp;amp;&amp;amp; node lifecycle-e2e.mjs 9345 http://localhost:3333) 2&amp;gt;&amp;amp;1 | tee "$SP/e2e.log"
kill $CHROME $SERVER 2&amp;gt;/dev/null; wait $CHROME $SERVER 2&amp;gt;/dev/null; pkill -f "scratchpad/chrome-p5e"; true&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Upserted:
1) Suspected dead → Mark fix merged
  ok   only "Mark fix merged" is enabled (✓ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection)
  ok   published: status fix-merged, fixMergedAt today (UTC) (1.4s)
  ok   no draft left behind
  ok   now waiting 7 days to bury, resurrection allowed (✗ 🩹 Mark fix merged | ✗ 🪦 Can bury in 7 days | ✓ 🧟 Report resurrection)
  ok   grave page shows the fix date (1.2s)
2) Fixed 3 days ago
  ok   "Can bury in 4 days", disabled (✗ 🩹 Mark fix merged | ✗ 🪦 Can bury in 4 days | ✓ 🧟 Report resurrection)
3) Fixed 8 days ago → Declare buried
  ok   "Declare buried" is enabled (✗ 🩹 Mark fix merged | ✓ 🪦 Declare buried | ✓ 🧟 Report resurrection)
  ok   published: status buried, buriedAt today (UTC) (1.5s)
  ok   no draft left behind
  ok   grave page shows the burial date (1.1s)
4) Cancelling a resurrection changes nothing
  ok   confirm dialog shows the message
  ok   clicked Cancel
  ok   no zombie was created
5) Unpublished edits block the actions
  ok   all three disabled (✗ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection)
6) Buried → Report resurrection → Zombie #1
  ok   clicked "Let it rise"
  ok   Studio opened the new zombie (c3812e41-4f19-495e-8011-60a6158d9802)
  ok   name "(Zombie #1)" (Lifecycle test: buried (Zombie #1))
  ok   status zombie, born today, timesResurrected 1 ({"s":"zombie","b":"2026-09-28","t":1})
  ok   previousLife → the buried bug
  ok   same language and cause, no epitaph yet
  ok   unique slug (lifecycle-test-buried-zombie-1)
  ok   zombie is published, with no draft
  ok   open grave tab shows "Risen from this grave" without reloading (1.4s)
  ok   homepage lists the zombie (0.2s)
  ok   the old grave cannot rise twice (0.3s)
7) Zombie #1 → Mark fix merged → Report resurrection → Zombie #2
  ok   a walking zombie can have its fix merged (✓ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection)
  ok   zombie #1 is fix-merged today (1.8s)
  ok   name "(Zombie #2)", not "(Zombie #1) (Zombie #2)" (Lifecycle test: buried (Zombie #2))
  ok   timesResurrected 2, previousLife → zombie #1
  ok   zombie #2 page shows both past lives, oldest first (0.5s)
8) No stray drafts anywhere
  ok   only the draft we made on purpose exists (["drafts.test-bug-life-drafted"])
  ok   no errors in the Studio
ZOMBIES=a6b031eb-78c1-4104-a5af-8565629d2597,c3812e41-4f19-495e-8011-60a6158d9802
all checks passed&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;npx sanity documents delete a6b031eb-78c1-4104-a5af-8565629d2597 2&amp;gt;&amp;amp;1 | grep ...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ npx sanity documents delete a6b031eb-78c1-4104-a5af-8565629d2597 2&amp;gt;&amp;amp;1 | grep -v "npm warn" | tail -1; npx sanity documents delete c3812e41-4f19-495e-8011-60a6158d9802 2&amp;gt;&amp;amp;1 | grep -v "npm warn" | tail -1; npx sanity documents delete test-bug-life-suspected test-bug-life-waiting test-bug-life-ready test-bug-life-buried test-bug-life-drafted drafts.test-bug-life-drafted 2&amp;gt;&amp;amp;1 | grep -v "npm warn" | tail -1
echo "left over: $(npx --yes sanity@latest documents query --api-version 2026-09-28 '*[_type == "bug" &amp;amp;&amp;amp; (name match "Lifecycle test*" || string::startsWith(_id, "test-bug-life") || string::startsWith(_id, "drafts.test-bug-life"))]._id' 2&amp;gt;/dev/null | tr -d ' \n')"; echo "bugs now: $(npx --yes sanity@latest documents query --api-version 2026-09-28 '*[_type == "bug" &amp;amp;&amp;amp; !(_id in path("drafts.**"))].name' 2&amp;gt;/dev/null | tr -d '\n' | sed 's/  */ /g')"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;Deleted 1 document
Deleted 1 document
Deleted 6 documents
left over: []
bugs now: [ "NullPointerException in checkout", "Timezone bug in scheduler", "Timezone bug in scheduler (Zombie #1)"]&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Everything is cleaned up, and the dataset is back to your 3 bugs. Next: the final build, lint and type-check.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;npx tsc --noEmit &amp;amp;&amp;amp; echo "tsc ok"; npm run lint --silent &amp;amp;&amp;amp; echo "lint ok"; n...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ npx tsc --noEmit &amp;amp;&amp;amp; echo "tsc ok"; npm run lint --silent &amp;amp;&amp;amp; echo "lint ok"; npx sanity schemas validate 2&amp;gt;&amp;amp;1 | grep -E "Errors|Warnings"; npm run build 2&amp;gt;&amp;amp;1 | grep -E "Compiled|TypeScript|rror"; git status --short&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;tsc ok
lint ok
✖ Errors:   0 errors
⚠ Warnings: 0 warnings
✓ Compiled successfully in 698ms
  Running TypeScript ...
  Finished TypeScript in 923ms ...
 M sanity.config.ts
?? sanity/actions/
?? sanity/lib/lifecycle.ts&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;All clean: type-check, lint, 0 schema errors and 0 warnings, and a passing build. I'll commit the code, deploy it to Vercel, and check the live Studio before writing the NOTES entry.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;git add sanity.config.ts sanity/actions sanity/lib/lifecycle.ts &amp;amp;&amp;amp; git commit...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ git add sanity.config.ts sanity/actions sanity/lib/lifecycle.ts &amp;amp;&amp;amp; git commit -q -F - &amp;lt;&amp;lt;'EOF'
Add lifecycle document actions to the Studio

Bugs get three actions after Publish: "Mark fix merged" (from suspected
dead or zombie, sets fixMergedAt), "Declare buried" (once the fix has
held for BURIAL_WAIT_DAYS = 7 days, sets buriedAt; otherwise shows
"Can bury in N days"), and "Report resurrection" (after a confirm
dialog, creates a published zombie with previousLife, timesResurrected
+ 1 and a unique slug, then opens it). Zombies go through the same
cycle, so zombies of zombies work.

The actions patch the published document directly, guarded by its
revision, so they never leave drafts, and they are disabled while
unpublished edits exist. A grave rises only once. Dates are UTC.

Co-Authored-By: Claude Opus 5.5 &amp;lt;noreply@anthropic.com&amp;gt;
EOF
git log --oneline | head -1
npx vercel@60 deploy --prod --yes 2&amp;gt;&amp;amp;1 | grep -E "Production:|Aliased|Error|error|READY|https://bug-graveyard" | grep -v "vercel curl\|vercel inspect" | head -6&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;4352c77 Add lifecycle document actions to the Studio
  Production      https://bug-graveyard-ab38mxs28-jashanpreet-kaur.vercel.app
[2K[1A[2K[G  Production      https://bug-graveyard-ab38mxs28-jashanpreet-kaur.vercel.app
▲ Aliased         https://bug-graveyard.vercel.app
    "url": "https://bug-graveyard-ab38mxs28-jashanpreet-kaur.vercel.app",
    "readyState": "READY",&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;It's deployed and aliased to &lt;a href="https://bug-graveyard.vercel.app" rel="noopener noreferrer"&gt;https://bug-graveyard.vercel.app&lt;/a&gt;. Next is a &lt;strong&gt;read-only&lt;/strong&gt; check of the live Studio: it opens your three real bugs and reads their action menus without clicking anything.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ SP=/private/tmp/claude-501/-Users-jashanpreetkaur-Desktop-bug-graveyard/0adab70e-beca-4347-ad08-acf0d3b85074/scratchpad
cat &amp;gt; "$SP/live-menus.mjs" &amp;lt;&amp;lt;'EOF'
// Read-only: opens real bugs in the live Studio and reads their lifecycle menus. Clicks nothing.
import {openStudio, wait} from './studio-cdp.mjs'
const [, , port, base] = process.argv
const s = await openStudio(port, base, 'rzjmw6lg')
let failures = 0
async function menu() {
  await s.evaluate(`document.querySelector('[data-testid="action-menu-button"]').click()`)
  await s.until('menu', `document.querySelectorAll('[role="menuitem"]').length &amp;gt; 0`)
  const items = await s.evaluate(`[...document.querySelectorAll('[role="menuitem"]')].filter(b =&amp;gt; /fix merged|bur|resurrection/i.test(b.innerText)).map(b =&amp;gt; (b.getAttribute('aria-disabled') === 'true' || b.disabled || b.hasAttribute('data-disabled') ? '✗ ' : '✓ ') + b.innerText.trim()).join(' | ')`)
  await s.evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}))`)
  await wait(300)
  return items
}
for (const [id, want] of [
  ['test-bug-timezone-scheduler-zombie-1', '✓ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection'],
  ['test-bug-timezone-scheduler', '✗ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection'],
  ['test-bug-nullpointer-checkout', '✗ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✓ 🧟 Report resurrection'],
]) {
  await s.navigate(`/studio/intent/edit/id=${id};type=bug`)
  await s.until(id, `location.pathname.endsWith(${JSON.stringify(id)}) &amp;amp;&amp;amp; !!document.querySelector('[data-testid="action-menu-button"]')`, 60000)
  let got
  for (let i = 0; i &amp;lt; 10 &amp;amp;&amp;amp; got !== want; i++) { await wait(1000); got = await menu() }
  if (got !== want) failures++
  console.log(`  ${got === want ? 'ok  ' : 'FAIL'} ${id}: ${got}`)
}
console.log('  studio errors:', s.logs.length ? s.logs.slice(0, 2) : 'none')
console.log(failures ? `${failures} FAILED` : 'live Studio has the actions')
s.close()
EOF
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --no-first-run --remote-debugging-port=9346 --user-data-dir="$SP/chrome-live5" about:blank &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp; CHROME=$!
curl -s -o /dev/null --retry 30 --retry-connrefused --retry-delay 1 http://127.0.0.1:9346/json/version
(cd "$SP" &amp;amp;&amp;amp; node live-menus.mjs 9346 https://bug-graveyard.vercel.app)
kill $CHROME 2&amp;gt;/dev/null; wait $CHROME 2&amp;gt;/dev/null; pkill -f "scratchpad/chrome-live5"
for p in / /grave/timezone-bug-in-scheduler; do printf "  live %-34s %s\n" "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://bug-graveyard.vercel.app$p)"; done&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;ok   test-bug-timezone-scheduler-zombie-1: ✓ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection
  ok   test-bug-timezone-scheduler: ✗ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✗ 🧟 Report resurrection
  ok   test-bug-nullpointer-checkout: ✗ 🩹 Mark fix merged | ✗ 🪦 Declare buried | ✓ 🧟 Report resurrection
  studio errors: none
live Studio has the actions
  live /                                  200
  live /grave/timezone-bug-in-scheduler   200&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
              &lt;div class="agent-session-tool-call"&gt;
                
                  &lt;span class="agent-session-chevron"&gt;▸&lt;/span&gt;
                  &lt;span class="tool-name tool-name-bash"&gt;Bash&lt;/span&gt;
                    &lt;span class="tool-input-preview"&gt;tail -2 NOTES.md &amp;amp;&amp;amp; cat &amp;gt;&amp;gt; NOTES.md &amp;lt;&amp;lt;'EOF'

---

## Phase 5: Lifecycle docum...&lt;/span&gt;
                
                &lt;div class="agent-session-tool-detail"&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Input&lt;/div&gt;
                      &lt;pre class="agent-session-pre agent-session-terminal"&gt;&lt;code&gt;$ tail -2 NOTES.md &amp;amp;&amp;amp; cat &amp;gt;&amp;gt; NOTES.md &amp;lt;&amp;lt;'EOF'

---

## Phase 5: Lifecycle document actions (2026-09-29)

### What I asked for

- Three custom Studio actions on bugs, registered through `document.actions` in
  `sanity.config.ts`, keeping Sanity's own Publish, Delete and so on:
  - **🩹 Mark fix merged**, enabled for suspected-dead bugs: status becomes fix-merged,
    `fixMergedAt` becomes today, and the change is published.
  - **🪦 Declare buried**, enabled once a fix has held for 7 days; otherwise the label
    says how long is left ("Can bury in 4 days"). Status becomes buried, `buriedAt`
    becomes today, and the change is published.
  - **🧟 Report resurrection**, for fix-merged or buried bugs. After a confirm dialog
    ("Are you sure? This bug will rise from its grave.") it creates a new zombie bug:
    `previousLife` points at this bug, `timesResurrected` goes up by one, the name
    becomes "&amp;lt;name&amp;gt; (Zombie #n)", with the same language and cause, a unique slug and
    today as `bornAt`. Then it opens the zombie so I can write its epitaph.
- UTC dates. Drafts handled properly: act on the published document and leave no
  stray drafts. Zombies go through the same lifecycle, so zombies of zombies work. The
  7-day rule in a single constant.
- Test it end to end with temporary bugs, check that the site updates live, run build,
  lint and type-check, add this entry, commit and deploy. Explain document actions for
  the write-up.

### What was built

- `sanity/actions/lifecycle.tsx`: the three actions
- `sanity/lib/lifecycle.ts`: `BURIAL_WAIT_DAYS = 7` and small helpers: `todayUTC`,
  `daysBetween`, `daysUntilBurial`, `zombieName`, `slugify`. It doesn't import
  `sanity`, so the site could use it too.
- `sanity.config.ts`: `document.actions` adds the three actions right after Publish,
  for bugs only

**Decisions that go beyond the brief:**
- **"Mark fix merged" also works on a walking zombie.** Without that, a zombie could
  never start its own lifecycle.
- **The actions write straight to the published document,** in one change guarded by
  its revision (`ifRevisionId`). There is no draft step, so nothing gets left behind.
  They are disabled while the bug has unpublished edits ("Publish or discard your
  changes first"), because that draft would later overwrite the new status.
- **The zombie is created already published,** so it appears on the site straight
  away, then opens in the Studio. Writing its epitaph is then an ordinary edit and
  publish.
- **A grave only rises once.** "Report resurrection" is disabled on a grave a zombie
  already came from ("It already rose as …"), and this is checked again at the moment
  of confirming. The next resurrection belongs on the zombie's own grave, which keeps
  every chain a single line.
- **The zombie copies the language and cause of death, but not the severity.** Its
  slug gets `-2`, `-3` and so on if the name is already taken.

**End-to-end test:** a production build, with headless Chrome clicking the real Studio
buttons. Five temporary bugs covered every situation, and **all 34 checks passed**:
- **Enabled/disabled states** in each status, including "🪦 Can bury in 7 days" right
  after a fix and "Can bury in 4 days" for a fix 3 days old.
- **Mark fix merged and Declare buried** wrote today's UTC date, and no drafts were
  left behind.
- **Cancel in the confirm dialog** created nothing.
- **Unpublished edits** disabled all three actions.
- **Resurrecting a buried bug** created "(Zombie #1)" with the correct fields and a
  unique slug, and opened it in the Studio.
- **A grave tab that was already open** showed "Risen from this grave" 1.4s later,
  without reloading.
- **The old grave could not rise twice.**
- **A zombie of a zombie** was named "(Zombie #2)", not "(Zombie #1) (Zombie #2)", and
  its page lists both past lives, oldest first.

Every temporary document was deleted afterwards. After the deploy, a read-only check of
the live Studio showed the right menu for each of the three real bugs.

### What went wrong and how we fixed it

- **"Report resurrection" stayed disabled right after another action.** My first
  version disabled it while it was still checking whether the grave had already
  risen. Sanity re-creates the action component whenever the document changes, so
  each change restarted that check, and the button was disabled for a second or two.
  The test clicked during that window, and the confirm dialog never opened. Fix: don't
  block while checking. The button is only disabled once a zombie is known to exist,
  and the final check happens when you confirm.
- **Test script problem, not an app bug:** headless Chrome slows down tabs in the
  background, so the Studio stalled after the test opened a second tab.
  `Page.bringToFront` fixed it.
- **Signing the headless Studio in:** the Studio keeps its token in `localStorage`
  under `__studio_auth_token_&amp;lt;projectId&amp;gt;`. The test put my CLI login token there
  before the page loaded, in a throwaway Chrome profile, and never printed it.
- **Still open: backfilling old bugs.** `fixMergedAt`, `buriedAt` and
  `timesResurrected` are read-only in the Studio, and the actions always use today. So
  a bug fixed months ago can't be given its real dates by hand; only a script or the
  API can do that.

### Sanity notes for the write-up

**How document actions work, in plain words:**
- The buttons at the bottom of a document in Sanity Studio (Publish, Duplicate, Delete,
  and so on) are all *document actions*, and you can add your own.
- **An action is a small React component.** Sanity renders it for the open document
  and gives it the document's current state: the published version, any unpublished
  draft, its ID and type. It returns a description of a button: a label, whether it's
  disabled, a tooltip, what happens on click, and optionally a dialog such as a
  confirm box.
- **It updates itself.** Because it's a component, Sanity re-renders it whenever the
  document changes. That's how "Can bury in 4 days" and the enabled/disabled states
  stay correct without any refresh logic.
- **It can use the Studio's hooks:** `useClient` to write data, `useRouter` to open
  another document, and `useDocumentStore().listenQuery` for a live GROQ subscription.
- **It runs as the signed-in person.** The action runs in the browser with that
  person's Sanity permissions, so there's no API token in the code.
- **You register actions in `sanity.config.ts`.** `document.actions` is a function
  that receives Sanity's default list plus some context (which type of document, and
  so on) and returns the list to show. The first one becomes the big button; the rest
  go in the "…" menu. Here the lifecycle actions go right after Publish, and only for
  bugs.

**Other things worth mentioning:**
- **Actions can own read-only fields.** Marking fields `readOnly` stops people editing
  them by hand while code can still set them. That turns the dates and the
  resurrection counter into a record of what actually happened.
- **Drafts are separate documents.** Editing creates a `drafts.&amp;lt;id&amp;gt;` copy, and
  publishing copies it over the real one. These actions skip that step and change the
  published document directly, with `ifRevisionId` (the write fails if someone else
  changed the bug first).
- **The built-in dialogs are declarative:** `dialog: {type: 'confirm', message,
  onConfirm, onCancel}`, and `navigateIntent('edit', {id, type})` opens a document.
- **The whole lifecycle runs on the same live pipeline as the site.** Clicking an
  action updates the homepage and grave pages within about a second, through the
  Studio's `&amp;lt;SanityLive /&amp;gt;` and the webhook.
EOF
git add NOTES.md &amp;amp;&amp;amp; git commit -q -m "Add the Phase 5 notes

Co-Authored-By: Claude Opus 5.5 &amp;lt;noreply@anthropic.com&amp;gt;" &amp;amp;&amp;amp; GIT_TERMINAL_PROMPT=0 git push 2&amp;gt;&amp;amp;1 | tail -1; git log --oneline | head -2; git status --short | wc -l | xargs echo "uncommitted files:"&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                    &lt;div class="tool-section"&gt;
                      &lt;div class="tool-section-label"&gt;Output&lt;/div&gt;
                      &lt;pre class="agent-session-pre"&gt;&lt;code&gt;- **Which Sanity CLI runs matters.** `npx sanity` is the project's CLI (v5), and
  `npx sanity@latest` is the newest (v8), which adds `api`, `organizations` and more.
   afae328..f638d72  main -&amp;gt; main
f638d72 Add the Phase 5 notes
4352c77 Add lifecycle document actions to the Studio
uncommitted files: 0&lt;/code&gt;&lt;/pre&gt;
                    &lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;The lifecycle actions are live in the Studio at &lt;a href="https://bug-graveyard.vercel.app/studio" rel="noopener noreferrer"&gt;https://bug-graveyard.vercel.app/studio&lt;/a&gt; (open a bug, then the "…" menu next to Publish). They're committed (&lt;code&gt;4352c77&lt;/code&gt;, notes in &lt;code&gt;f638d72&lt;/code&gt;), pushed and deployed. Type-check, lint, schema validation and the build all pass.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;What's there:&lt;/strong&gt; &lt;a href="sanity/actions/lifecycle.tsx"&gt;sanity/actions/lifecycle.tsx&lt;/a&gt; holds the three actions, and &lt;a href="sanity/lib/lifecycle.ts"&gt;sanity/lib/lifecycle.ts&lt;/a&gt; holds &lt;code&gt;BURIAL_WAIT_DAYS = 7&lt;/code&gt; plus the date helpers. &lt;a href="sanity.config.ts"&gt;sanity.config.ts&lt;/a&gt; adds the actions right after Publish, for bugs only. Dates are UTC, so at 02:00 in India they record the previous day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decisions that go beyond your brief:&lt;/strong&gt;&lt;br&gt;
- &lt;strong&gt;"Mark fix merged" also works on a walking zombie.&lt;/strong&gt; Otherwise a zombie could never start its own lifecycle.&lt;br&gt;
- &lt;strong&gt;The actions change the published bug directly,&lt;/strong&gt; in a single change that fails if someone else edited the bug first. No draft is ever created. They're disabled while you have unpublished edits, because your draft would later overwrite the new status.&lt;br&gt;
- &lt;strong&gt;The new zombie is created already published,&lt;/strong&gt; so it shows on the site straight away, then opens for you to write its epitaph.&lt;br&gt;
- &lt;strong&gt;A grave only rises once.&lt;/strong&gt; A second resurrection belongs on the zombie's grave, which keeps each chain a single line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I tested it:&lt;/strong&gt; a script clicked the real Studio buttons against a production build, using five temporary bugs, and all 34 checks passed. They cover:&lt;br&gt;
- every enabled/disabled state, including "🪦 Can bury in 4 days";&lt;br&gt;
- today's UTC dates, with no drafts left behind;&lt;br&gt;
- cancelling the confirm dialog;&lt;br&gt;
- being blocked by unpublished edits;&lt;br&gt;
- zombies of zombies (named "(Zombie #2)");&lt;br&gt;
- the site updating within about 1.5 seconds, including an already open grave page.&lt;/p&gt;

&lt;p&gt;The test bugs are deleted. After deploying, a read-only check confirmed the live Studio shows the right menu for your 3 real bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bug the test caught:&lt;/strong&gt; right after one action changed a bug, "Report resurrection" was briefly disabled. My check for "has this grave already risen?" restarted after every change and blocked the button while running. It now only disables once a zombie is known to exist, and checks again when you confirm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still open:&lt;/strong&gt; the dates and resurrection count are read-only and the actions always use today. So an old bug can't be given its real historical dates in the Studio; only a script or the API can do that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be careful trying it:&lt;/strong&gt; these actions change your real data. "Report resurrection" on "NullPointerException in checkout" will create a real zombie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For your write-up: how document actions work&lt;/strong&gt;&lt;br&gt;
- Every button at the bottom of a Sanity document (Publish, Duplicate, Delete) is a &lt;em&gt;document action&lt;/em&gt;, and you can add your own.&lt;br&gt;
- An action is a small React component. Sanity gives it the document's current state (the published version and any unpublished draft), and it describes a button: label, disabled or not, tooltip, what happens on click, and optionally a confirm dialog.&lt;br&gt;
- Because it's a component, it updates whenever the document changes. That keeps labels like "Can bury in 4 days" current with no extra code.&lt;br&gt;
- It runs in the browser as the signed-in person, using their permissions, so there's no API token in the code.&lt;br&gt;
- You register actions in &lt;code&gt;sanity.config.ts&lt;/code&gt; with &lt;code&gt;document.actions&lt;/code&gt;. That function receives Sanity's default list and returns the list to show. The first one becomes the big button, and the rest go in the "…" menu.&lt;/p&gt;

&lt;p&gt;There's a longer version of this in the Phase 5 entry of &lt;a href="NOTES.md"&gt;NOTES.md&lt;/a&gt;.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        41 messages (range 358-398) of 719 total
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;



&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explainable beats impressive.&lt;/strong&gt; Four signals and a sum are easy to trust and easy to test: the same bug always gets the same score, and the AI only explains a match that has already passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured content is memory.&lt;/strong&gt; The detector only works because every grave already had a cause, a language, a component and symptoms as fields, not as a paragraph of prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document actions go a long way.&lt;/strong&gt; They're just React components that re-render with the document, so a live countdown like "Can bury in 4 days" needs no refresh logic. And they run as the signed-in editor, so there's no token in the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test against a production build from day one.&lt;/strong&gt; &lt;code&gt;npm run dev&lt;/code&gt; hid both live-update bugs. I'd also connect Vercel to GitHub at the start: I only added the connection near the end, so most early deploys were a manual &lt;code&gt;vercel deploy --prod&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directing an agent works best in small phases,&lt;/strong&gt; each with a clear brief, a test and a build-log entry. That log is also what this post was fact-checked against.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real fixes.&lt;/strong&gt; Fill &lt;code&gt;fixUrl&lt;/code&gt; from merged pull requests (a GitHub webhook into a Function), so "the previous fix" links straight to the diff. The 18 graves here are invented, so their &lt;code&gt;fixUrl&lt;/code&gt; is empty on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learn from dismissals.&lt;/strong&gt; Every dismissed match keeps its signals, which is exactly the data needed to tune the weights and the threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fifth signal, clearly labelled.&lt;/strong&gt; A semantic comparison of symptoms could join the four, still shown ✓/✗ with its points, never as a mystery percentage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading. May your bugs rest in peace, and may the graveyard catch them when they don't. 🪦&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
