<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Creative Agent Studio on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/tags/creative-agent-studio/</link><description>Recent content in Creative Agent Studio on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 28 May 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/tags/creative-agent-studio/index.xml" rel="self" type="application/rss+xml"/><item><title>Creative Agent Studio #5 — Polish Week: LLM Token Telemetry, Multi-Session Isolation, Inline Rewind, and the Revision-Request Affordance</title><link>https://ice-ice-bear.github.io/posts/2026-05-28-creative-agent-studio-dev5/</link><pubDate>Thu, 28 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-28-creative-agent-studio-dev5/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post Creative Agent Studio #5 — Polish Week: LLM Token Telemetry, Multi-Session Isolation, Inline Rewind, and the Revision-Request Affordance" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-05-22-creative-agent-studio-dev4/" &gt;Previous post: #4 — five gates, four canvases, and the revise-mode system&lt;/a&gt; ended the build-out push. Six days later, &lt;strong&gt;23 commits over four working days&lt;/strong&gt; closed out polish week — the kind of work that turns a feature-complete system into something that holds up in production under a real user&amp;rsquo;s hands.&lt;/p&gt;
&lt;p&gt;Four threads ran in parallel through the week. First, &lt;strong&gt;LLM-call observability&lt;/strong&gt; — every model invocation now emits structured logs with token usage and a propagated logCtx, plus the runtime got &lt;code&gt;LOG_LEVEL&lt;/code&gt; threshold control. Second, &lt;strong&gt;per-project state isolation under refresh&lt;/strong&gt; — eight commits on 2026-05-26 hunted down hooks-order bugs, stranded bootstraps, and crypto.randomUUID gaps that surfaced once the multi-session work landed. Third, &lt;strong&gt;inline past-gate rewind&lt;/strong&gt; — a single feat commit shipped the affordance that lets users say &amp;ldquo;change the third copy back at GATE 2&amp;rdquo; from inside an active storyboard session without leaving. Fourth, &lt;strong&gt;the revision-request affordance pass&lt;/strong&gt; — five commits unified the marked-card visual across every canvas tab, softened the rewind dialog copy, and fixed a state bug where the gate selector forgot which card the user just clicked.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Prev["#4 — build-out (5cb4106)"] --&gt; Thread1["Thread 1 &amp;lt;br/&amp;gt; LLM observability &amp;lt;br/&amp;gt; token usage + logCtx"]
 Prev --&gt; Thread2["Thread 2 &amp;lt;br/&amp;gt; Per-project isolation &amp;lt;br/&amp;gt; hooks order + bootstrap fixes"]
 Prev --&gt; Thread3["Thread 3 &amp;lt;br/&amp;gt; Inline past-gate rewind &amp;lt;br/&amp;gt; chat-driven pipeline edits"]
 Prev --&gt; Thread4["Thread 4 &amp;lt;br/&amp;gt; Revision-request UX &amp;lt;br/&amp;gt; unified marked-card affordance"]
 Thread1 --&gt; End["#5 end (67b820e) &amp;lt;br/&amp;gt; 2026-05-28"]
 Thread2 --&gt; End
 Thread3 --&gt; End
 Thread4 --&gt; End&lt;/pre&gt;&lt;p&gt;One running theme — &lt;strong&gt;the features were done; what remained was making them honest under refresh, under load, and under a confused user.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="thread-1--llm-call-observability"&gt;Thread 1 — LLM-Call Observability
&lt;/h2&gt;&lt;p&gt;The structured logger that landed in #2 was the foundation; this week wired it through every LLM call site so production telemetry actually means something.&lt;/p&gt;
&lt;p&gt;Two commits did the heavy lifting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(logger): add LOG_LEVEL threshold, silence node:sqlite warning&lt;/code&gt; — a &lt;code&gt;LOG_LEVEL&lt;/code&gt; env var so production can drop the &lt;code&gt;info&lt;/code&gt; chatter and keep only &lt;code&gt;warn&lt;/code&gt;/&lt;code&gt;error&lt;/code&gt;. The companion change suppressed the &lt;code&gt;node:sqlite&lt;/code&gt; experimental-warning that printed on every worker fork.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(observability): emit structured logs across worker + lifecycle paths&lt;/code&gt; — &lt;code&gt;runtime/workers/worker-loop.js&lt;/code&gt; and &lt;code&gt;runtime/orchestration/run-lifecycle.js&lt;/code&gt; now log structured events at each meaningful transition (job claimed, run started, gate emitted, stage advanced, run completed/errored).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(observability): instrument LLM calls with token usage + logCtx&lt;/code&gt; — this is the load-bearing one. Every model call now logs:&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// runtime/agents/_llm-instrument.js (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;callModelWithLogging&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;logCtx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;llm_call&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;logCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;prompt_tokens&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;duration_ms&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;cached_tokens&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cached_input_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;llm_call_failed&amp;#34;&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="nx"&gt;logCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration_ms&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;logCtx&lt;/code&gt; is propagated from &lt;code&gt;worker-loop.js&lt;/code&gt; and carries &lt;code&gt;{ runId, projectId, stage, role }&lt;/code&gt;. So a single LLM call&amp;rsquo;s log line tells you which project, which session, which gate, which agent, which model — and how many tokens it cost. The Grafana dashboard set up back in #2 now has actual data flowing into it.&lt;/p&gt;
&lt;p&gt;The infra-side companion was &lt;code&gt;feat(terraform): widen EC2 start cron to every day&lt;/code&gt; + &lt;code&gt;fix(terraform): update daily stop schedule to 03:00 KST&lt;/code&gt;. The EC2 instance had been running on a weekday-only schedule from the prototype days; the cron now starts the box every day and stops it at 03:00 KST. Operational, not feature work.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="thread-2--per-project-state-isolation-under-refresh"&gt;Thread 2 — Per-Project State Isolation Under Refresh
&lt;/h2&gt;&lt;p&gt;The multi-session work in #4 introduced an entire class of refresh-time bugs. When the user hard-refreshes inside a workspace, the React tree mounts before any data is loaded — and the multi-session logic was making assumptions that only held if the user had navigated &lt;em&gt;into&lt;/em&gt; the workspace from the launcher (where the bootstrap had already happened).&lt;/p&gt;
&lt;p&gt;Eight commits on 2026-05-26 hunted down the edge cases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix(web): bootstrap projects on workspace refresh, fix hooks order&lt;/code&gt;&lt;/strong&gt; — the workspace page assumed &lt;code&gt;projects&lt;/code&gt; was already loaded. It wasn&amp;rsquo;t on a refresh. The fix added a bootstrap call inside the workspace effect — but that triggered a React hooks-order violation because the bootstrap call was conditionally inside a &lt;code&gt;useEffect&lt;/code&gt;. Both got fixed in one commit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix(web): isolate per-project state on switch, show loader during session hydration&lt;/code&gt;&lt;/strong&gt; — switching from project A to project B was leaving A&amp;rsquo;s session list visible while B was loading. The fix: when &lt;code&gt;projectId&lt;/code&gt; changes, immediately clear the per-project slices and show a loader, then hydrate B&amp;rsquo;s data. The user sees a brief loader instead of mistakenly attributing A&amp;rsquo;s sessions to B.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix(web): don't strand /sessions bootstrap behind a stale ref guard&lt;/code&gt;&lt;/strong&gt; — there was a &amp;ldquo;don&amp;rsquo;t re-bootstrap if already bootstrapping&amp;rdquo; guard implemented with a ref. The ref was being set to &lt;code&gt;true&lt;/code&gt; and then never cleared on certain error paths, so subsequent navigations got stranded. Fix: track the bootstrap state in the slice instead of in a ref, and clear it on success &lt;em&gt;and&lt;/em&gt; failure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix(web): make Composer first-brief-only, flip ApproveBar toggle label&lt;/code&gt;&lt;/strong&gt; — the Composer (chat input) had been the entry point for &lt;em&gt;every&lt;/em&gt; user turn. But once a session was past the first brief, the gate-based flow took over — the Composer&amp;rsquo;s role should be hidden. The fix made the Composer visible only before the first brief is submitted; after that, the ApproveBar is the user&amp;rsquo;s surface.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix(web): make bootstrap dep stable so /sessions can't be cancelled forever&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;useChatStream&lt;/code&gt; was using an AbortController whose dependency array was unstable, causing it to abort on every re-render. The fix stabilized the dep so the controller only aborted on actual user cancel or unmount.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix(web): polyfill crypto.randomUUID so HTTP prod can create projects/sessions&lt;/code&gt;&lt;/strong&gt; — the production EC2 was serving over plain HTTP for some clients (an intermediate proxy stripped HTTPS), and &lt;code&gt;crypto.randomUUID&lt;/code&gt; is only available in secure contexts. Projects and sessions couldn&amp;rsquo;t be created. The polyfill restores it.&lt;/p&gt;
&lt;p&gt;The last one is the most &amp;ldquo;production reveals what your dev environment hid&amp;rdquo; of the week. Localhost is a secure context. The deploy target isn&amp;rsquo;t always. A one-line polyfill kept the EC2 build usable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix: preserve 분석 보고서 across key-concept revisions&lt;/code&gt;&lt;/strong&gt; — the runtime fix companion. When the user revised the key-concept selection at GATE 1, the analysis report (도서 단계의 산출물) was being recomputed alongside, which was unnecessary work and produced a slightly different report each time. The fix kept the original analysis frozen across key-concept revisions.&lt;/p&gt;
&lt;p&gt;And &lt;code&gt;docs(claude): register Diffs Runtime harness pointer in CLAUDE.md&lt;/code&gt; added a pointer so Claude Code sessions could find the runtime&amp;rsquo;s harness conventions automatically.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="thread-3--inline-past-gate-rewind-from-chat"&gt;Thread 3 — Inline Past-Gate Rewind From Chat
&lt;/h2&gt;&lt;p&gt;A single commit shipped a major UX move: &lt;code&gt;feat(web): add inline past-gate rewind for chat-driven pipeline edits&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The setup: by polish week, the user had ApproveBar with revise-mode for the current gate, and the workflow advanced one stage at a time. But what if the user wanted to change a &lt;em&gt;past&lt;/em&gt; decision? &amp;ldquo;Actually, go back to the second key concept&amp;rdquo; while sitting in the storyboard stage?&lt;/p&gt;
&lt;p&gt;Previously this required leaving the canvas, navigating back to GATE 1 manually, reselecting, and walking forward through every subsequent gate again. Painful, and a violation of the chat-first principle.&lt;/p&gt;
&lt;p&gt;The inline rewind detects past-gate intent in chat:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// (paraphrased — combined chat-stream + dispatch-sse path)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Backend classifies the chat message:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// &amp;#34;두 번째 키 컨셉으로 다시 가자&amp;#34; → rewind_intent: { gate: &amp;#34;GATE_1&amp;#34;, selection: 2 }
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Frontend receives a rewind_proposal SSE event:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rewind_proposal&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromGate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;GATE_5&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;toGate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;GATE_1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;affectedDownstreamGates&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;GATE_2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;GATE_3&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;GATE_4&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;GATE_5&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// UI shows the inline ConfirmRewindDialog (the one polished in Thread 4)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The dialog explicitly lists what will be discarded — copy approval at GATE 2, scenario approval at GATE 4, etc. — so the user knows the cost before confirming. On confirm, the runtime walks the project back to the target gate, regenerates everything downstream, and the user lands at the target gate&amp;rsquo;s approval surface ready to make the new choice.&lt;/p&gt;
&lt;p&gt;This is the natural extension of the gate-based-auto-run principle (decision 3 in &lt;code&gt;interaction-model.md&lt;/code&gt;) — but in &lt;em&gt;reverse&lt;/em&gt;. The principle says: each stage advances to the next on user approval. The rewind says: each stage can also walk &lt;em&gt;back&lt;/em&gt;, and the runtime knows what to invalidate.&lt;/p&gt;
&lt;p&gt;The companion &lt;code&gt;docs(claude): add triage-prod-bug skill trigger to enforce browser-first debugging&lt;/code&gt; is a harness rule — when a bug is reported, look at the browser first (devtools, network, console) before reading any code. The chat-first product principle has a debugging analogue: see-first, code-second.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="thread-4--the-revision-request-affordance-pass"&gt;Thread 4 — The Revision-Request Affordance Pass
&lt;/h2&gt;&lt;p&gt;The final five commits unified the revision-request UI across every canvas tab. Before this work, every tab had implemented its own marked-card style — yellow background on Copy, red dashed border on KeyConcept, blue left-edge bar on Storyboard (only this one), no visual treatment at all on Scene — and users kept asking &lt;em&gt;&amp;ldquo;why does only 콘티 (Storyboard) show this blue mark?&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;h3 id="soften-rewind-dialog-copy--hide-final-gate-from-discard-list"&gt;Soften rewind dialog copy + hide final gate from discard list
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;e27316a&lt;/code&gt;. The first attack was the most jarring piece of copy. The rewind dialog read:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&amp;ldquo;이 결정을 되돌릴까요?&amp;rdquo;
&amp;ldquo;카피 검토 단계부터 다시 진행합니다.&amp;rdquo;
&amp;ldquo;아래 후속 결정이 새 버전으로 대체됩니다:&amp;rdquo;
– 컨셉 확정 결정
– 시나리오 검토 결정
– &lt;strong&gt;최종 승인 결정&lt;/strong&gt; ← shouldn&amp;rsquo;t be in this list&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Two problems: the discard list included the &lt;em&gt;final&lt;/em&gt; approval gate (but that gate is what you would re-confirm at the end, not something that vanishes), and &amp;ldquo;결정&amp;rdquo; felt corporate when the actual artifact is a creative judgment about a draft.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/components/approve/ConfirmRewindDialog.tsx
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt; &lt;span class="na"&gt;data-testid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;confirm-rewind-discard-list&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;gates&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;final-approval&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&amp;gt;{&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;softTitle&lt;/span&gt;&lt;span class="p"&gt;}&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Filter &lt;code&gt;final-approval&lt;/code&gt; gates out of the discard preview, and use &lt;code&gt;softTitle&lt;/code&gt; (e.g., &amp;ldquo;콘티 검토&amp;rdquo; instead of &amp;ldquo;콘티 검토 결정&amp;rdquo;). Both come from the same &lt;code&gt;Gate&lt;/code&gt; interface but rendered differently in destructive vs. informational contexts.&lt;/p&gt;
&lt;h3 id="gate-titles-in-the-rewind-dialog"&gt;Gate titles in the rewind dialog
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;4ddff68&lt;/code&gt;. The dialog had been using machine-generated gate ids (&lt;code&gt;g-2&lt;/code&gt;, &lt;code&gt;g-3&lt;/code&gt;) as titles. A small label map fixed comprehension:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GATE_LABELS&lt;/span&gt;: &lt;span class="kt"&gt;Record&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;GateKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;concept-confirm&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;컨셉 검토&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;scenario-review&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;시나리오 검토&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;storyboard-approve&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;콘티 검토&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;cut-finalize&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;컷 검토&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;final-approval&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;최종 승인&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the dialog reads &amp;ldquo;콘티 검토 단계부터 다시 진행합니다&amp;rdquo; — a phrase that matches the canvas tab labels exactly, so users see in advance which tab they&amp;rsquo;ll land on after rewinding.&lt;/p&gt;
&lt;h3 id="approvebar-gate-preselection-on-수정요청-enter"&gt;ApproveBar gate preselection on 수정요청 enter
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;c55891b&lt;/code&gt;. When the user clicked 수정요청 on a card, the ApproveBar slid up with a gate selector — but the selector started empty, so the user had to re-click the same gate they just marked. A two-line &lt;code&gt;useEffect&lt;/code&gt; synced the implicit context into the explicit selection:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/components/approve/ApproveBar.tsx
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;useEffect&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;수정요청&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;activeCard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;setGateSelection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activeCard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;idle&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;setGateSelection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// clear on 닫기
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeCard&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A security-review pass on this change surfaced one note: the gate id flows from a user-controlled DOM event into a state field that gets passed to a server-side mutation. Since &lt;code&gt;gateId&lt;/code&gt; is validated server-side against the current workflow&amp;rsquo;s gate set anyway, no additional client-side validation was needed — but the review made that invariant explicit.&lt;/p&gt;
&lt;h3 id="unifying-the-marked-card-visual-across-five-tabs"&gt;Unifying the marked-card visual across five tabs
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;2804420&lt;/code&gt;. The visual unification pass. The unified treatment lives in a single design token:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// design-system / marked-card.css
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;position&lt;/span&gt;: &lt;span class="kt"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;outline&lt;/span&gt;: &lt;span class="kt"&gt;2px&lt;/span&gt; &lt;span class="nx"&gt;solid&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;outline&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;position&lt;/span&gt;: &lt;span class="kt"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;top&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;width&lt;/span&gt;: &lt;span class="kt"&gt;3px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;background&lt;/span&gt;: &lt;span class="kt"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And every tab component now wraps the card in:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;card&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;marked-card&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)}&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;RevisionLabel&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* tab-specific content */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;RevisionLabel&lt;/code&gt; was also extracted — previously each tab built its own label inline with different copy (&amp;ldquo;수정 요청됨&amp;rdquo;, &amp;ldquo;리비전&amp;rdquo;, &amp;ldquo;Edit Pending&amp;rdquo;). Now there is one component, one string.&lt;/p&gt;
&lt;h3 id="show-every-gate-marker-once-and-reflect-live-gate-state"&gt;Show every gate marker once and reflect live gate state
&lt;/h3&gt;&lt;p&gt;The very last commit of the day (&lt;code&gt;67b820e&lt;/code&gt;). The StageStepper at the top of the workspace was showing duplicate gate markers in some states (a re-emitted gate event would add a second dot) and was lagging behind the actual gate_state transitions. Two bugs in one fix:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Deduplicate by gate id so each gate renders exactly once&lt;/li&gt;
&lt;li&gt;Wire the live &lt;code&gt;gate_state&lt;/code&gt; from the pipeline slice (the 12-state field from #4) so the stepper reflects whatever transition just happened&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The 12-state gate_state field had been in place for a week, but the stepper had still been using the older &amp;ldquo;last completed gate&amp;rdquo; inference. This commit closed that gap.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="commit-log-23-total"&gt;Commit Log (23 total)
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Date&lt;/th&gt;
 &lt;th&gt;Message&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-25&lt;/td&gt;
 &lt;td&gt;fix stale approve gates during storyboard runs&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-25&lt;/td&gt;
 &lt;td&gt;feat(terraform): widen EC2 start cron to every day&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-25&lt;/td&gt;
 &lt;td&gt;refactor(canvas): drop submit prop drilling, polish selected-copy card&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-25&lt;/td&gt;
 &lt;td&gt;feat(logger): add LOG_LEVEL threshold, silence node:sqlite warning&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-25&lt;/td&gt;
 &lt;td&gt;feat(observability): emit structured logs across worker + lifecycle paths&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-25&lt;/td&gt;
 &lt;td&gt;feat(observability): instrument LLM calls with token usage + logCtx&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-25&lt;/td&gt;
 &lt;td&gt;chore: refresh lockfile peer-dep flags&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;fix(web): bootstrap projects on workspace refresh, fix hooks order&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;docs(claude): register Diffs Runtime harness pointer in CLAUDE.md&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;fix: preserve 분석 보고서 across key-concept revisions&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;fix(web): isolate per-project state on switch, show loader during session hydration&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;fix(web): don&amp;rsquo;t strand /sessions bootstrap behind a stale ref guard&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;fix(web): make Composer first-brief-only, flip ApproveBar toggle label&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;fix(web): make bootstrap dep stable so /sessions can&amp;rsquo;t be cancelled forever&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-26&lt;/td&gt;
 &lt;td&gt;fix(web): polyfill crypto.randomUUID so HTTP prod can create projects/sessions&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-27&lt;/td&gt;
 &lt;td&gt;docs(claude): add triage-prod-bug skill trigger to enforce browser-first debugging&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-27&lt;/td&gt;
 &lt;td&gt;feat(web): add inline past-gate rewind for chat-driven pipeline edits&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-27&lt;/td&gt;
 &lt;td&gt;fix(terraform): update daily stop schedule to 03:00 KST in variables&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-27&lt;/td&gt;
 &lt;td&gt;fix(gitignore): add harnesskit session-logs to .gitignore&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-28&lt;/td&gt;
 &lt;td&gt;fix(web): soften rewind dialog copy and hide final gate from discard list&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-28&lt;/td&gt;
 &lt;td&gt;fix(web): update gate titles in ConfirmRewindDialog for clarity&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-28&lt;/td&gt;
 &lt;td&gt;fix(web): preselect gateSelection on 수정요청 enter, clear it on 닫기&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-28&lt;/td&gt;
 &lt;td&gt;fix(web): unify 수정요청 marked-card visual and label across all tabs&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-05-28&lt;/td&gt;
 &lt;td&gt;fix(web): show every gate marker once and reflect live gate state&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;Polish week&amp;rsquo;s pattern: &lt;strong&gt;observability and isolation were both about exposing state that had been quietly assumed.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The LLM token telemetry exposed &lt;em&gt;what was actually costing money&lt;/em&gt; — the prior dashboards showed queue depth and job duration, both important, but a slow agent might be cheap and a fast agent might be expensive depending on token usage. Wiring &lt;code&gt;logCtx&lt;/code&gt; through every call surfaced the per-project, per-stage, per-agent breakdown the cost analysis needs.&lt;/p&gt;
&lt;p&gt;The multi-session refresh fixes exposed &lt;em&gt;what the workspace was assuming about its bootstrap&lt;/em&gt;. The features worked when the user navigated in from the launcher because the launcher&amp;rsquo;s bootstrap was a precondition that happened to be satisfied. When refresh broke that precondition, the bugs surfaced — but they had been there all along, latent.&lt;/p&gt;
&lt;p&gt;The inline past-gate rewind is the same idea applied to the user&amp;rsquo;s mental model — the user &lt;em&gt;thinks&lt;/em&gt; &amp;ldquo;I want to change something back at GATE 1.&amp;rdquo; The rewind feature exposed that intent to the system instead of forcing the user to translate it into navigation steps. The chat-first principle isn&amp;rsquo;t just an input mechanism; it&amp;rsquo;s a commitment that the system will understand intent.&lt;/p&gt;
&lt;p&gt;The revision-request affordance pass is the small visible tip of the same iceberg. Three layers of clarity — visual unity, honest copy, state preservation — applied to a destructive workflow action, so the dialog asking for confirmation tells the truth about what will be lost.&lt;/p&gt;
&lt;p&gt;Next: from here forward, the system is feature-complete enough that future dev logs will tilt toward production lessons rather than feature additions — agent prompt tuning, cost trending, the next round of UX patterns that emerge from real user sessions. The five-post backfill closes a clean arc: mockup, production-readying, megapush, gate workflow, polish. The product is real now.&lt;/p&gt;</description></item><item><title>Creative Agent Studio #4 — Five Gates, Four Canvases, and the Revise-Mode System</title><link>https://ice-ice-bear.github.io/posts/2026-05-22-creative-agent-studio-dev4/</link><pubDate>Fri, 22 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-22-creative-agent-studio-dev4/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post Creative Agent Studio #4 — Five Gates, Four Canvases, and the Revise-Mode System" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-05-19-creative-agent-studio-dev3/" &gt;Previous post: #3 — the 134-commit megapush&lt;/a&gt; ended with PR4 (ApproveSheet + three gate variants) merged and project state surviving reload. Three days later, &lt;strong&gt;153 more commits&lt;/strong&gt; had completed every remaining piece of the four-stage agent workflow: a four-variant canvas panel that materialized the stage outputs (PR5), a workflow consolidation from three gates to &lt;strong&gt;five&lt;/strong&gt; with the key-concept planner at the new GATE 1, a UI re-architecture that replaced the bottom-drawer ApproveSheet with a slim ApproveBar plus output tabs, and the &lt;strong&gt;revise-mode multi-select system&lt;/strong&gt; that lets users surgically re-run a single key concept, copy draft, cut, or storyboard panel instead of redoing an entire stage.&lt;/p&gt;
&lt;p&gt;Underneath, the runtime gained the Planner-Generator-Evaluator loop, continuity anchors authored from user feedback, evolution notes injected into prompts, an HTML template set for the planning report and storyboard, and session isolation so a single project can carry multiple parallel attempts.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Prev["#3 — megapush (9a2f851)"] --&gt; Theme1["Theme 1 &amp;lt;br/&amp;gt; PR5 — 4 stage canvases &amp;lt;br/&amp;gt; + resize + collapse"]
 Prev --&gt; Theme2["Theme 2 &amp;lt;br/&amp;gt; 5-gate workflow &amp;lt;br/&amp;gt; key-concept planner at GATE 1"]
 Prev --&gt; Theme3["Theme 3 &amp;lt;br/&amp;gt; ApproveBar + output tabs &amp;lt;br/&amp;gt; drawer replaced"]
 Prev --&gt; Theme4["Theme 4 &amp;lt;br/&amp;gt; Revise-mode system &amp;lt;br/&amp;gt; partial vs bulk revision"]
 Theme1 --&gt; End["#4 end (5cb4106) &amp;lt;br/&amp;gt; 2026-05-22"]
 Theme2 --&gt; End
 Theme3 --&gt; End
 Theme4 --&gt; End&lt;/pre&gt;&lt;p&gt;Four themes, one recurring shape — &lt;strong&gt;the workflow stopped being &amp;ldquo;all or nothing&amp;rdquo; at every layer it touched.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="theme-1--pr5-the-canvas-panel-with-four-stage-variants"&gt;Theme 1 — PR5: The Canvas Panel With Four Stage Variants
&lt;/h2&gt;&lt;p&gt;PR2 reserved a &lt;code&gt;CanvasPanel&lt;/code&gt; placeholder. PR5 filled it. The canvas is the right-hand column of the workspace; it shows the &lt;em&gt;current artifact&lt;/em&gt; of the current stage, rendered with a stage-specific component.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/components/canvas/stage-canvas.tsx (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;StageCanvas&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;: &lt;span class="kt"&gt;PresentationStage&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;research&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ResearchCanvas&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;copy&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;CopyCanvas&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;scenario&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ScenarioCanvas&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;storyboard&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;StoryboardCanvas&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each variant reads its data from the &lt;code&gt;pipeline&lt;/code&gt; slice and renders a stage-appropriate layout:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Stage&lt;/th&gt;
 &lt;th&gt;Variant&lt;/th&gt;
 &lt;th&gt;What it shows&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;research&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;ResearchCanvas&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;AdBriefRecap&lt;/code&gt; + &lt;code&gt;ResearchSummaryCards&lt;/code&gt; from &lt;code&gt;pipelineContext&lt;/code&gt; + active task list&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;copy&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;CopyCanvas&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;ConceptGrid&lt;/code&gt; of &lt;code&gt;CopyOptions&lt;/code&gt; from &lt;code&gt;copyHistory&lt;/code&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;scenario&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;ScenarioCanvas&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;SceneStrip&lt;/code&gt; per Act + &lt;code&gt;CutChip&lt;/code&gt; strip from &lt;code&gt;scenarioHistory&lt;/code&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;storyboard&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;StoryboardCanvas&lt;/code&gt;&lt;/td&gt;
 &lt;td&gt;1 &lt;code&gt;StoryboardPage&lt;/code&gt; per scene from &lt;code&gt;storyboardImageUrls&lt;/code&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Storyboard image handling needed a small architectural choice. Base64 PNG payloads arrive via &lt;code&gt;storyboard_image&lt;/code&gt; SSE events. The pipeline slice converts each to a blob URL (&lt;code&gt;URL.createObjectURL&lt;/code&gt;) with a revoke-on-replace step so a re-generation doesn&amp;rsquo;t leak the previous image. The &lt;code&gt;addStoryboardImage&lt;/code&gt; reducer holds the revoke logic:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;addStoryboardImage&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;old&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storyboardImageUrls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base64ToBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;image/png&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;storyboardImageUrls&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storyboardImageUrls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="resize--collapse"&gt;Resize + collapse
&lt;/h3&gt;&lt;p&gt;Two commits added the resize affordance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add useCanvasResizer hook (pointer capture + clamped delta -&amp;gt; ui.canvasWidth)&lt;/code&gt; — &lt;code&gt;ui.canvasWidth&lt;/code&gt; is clamped to 360..800px in the ui slice&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add CanvasResizer component (vertical drag handle + warm hover tint)&lt;/code&gt; — the visual handle, hover tint follows Creative Warmth (a subtle warm gradient, no pure highlight)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): WorkspacePage grid column reads ui.canvasWidth + respects canvasCollapsed&lt;/code&gt; — the grid actually responds to the width&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;CanvasHeader&lt;/code&gt; got a collapse toggle so power users can hide the canvas entirely when they want to focus on chat.&lt;/p&gt;
&lt;h3 id="pr6--polish--backend-swap--mockup-deletion"&gt;PR6 — Polish + backend swap + mockup deletion
&lt;/h3&gt;&lt;p&gt;PR6 was the bridge between &amp;ldquo;everything in React works&amp;rdquo; and &amp;ldquo;we&amp;rsquo;re actually serving React to users&amp;rdquo;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): ui slice gains fatalError + setFatalError&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ErrorScreen (vercel_unsupported + unexpected kinds, ko/en)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AppErrorBoundary class component (catches descendants -&amp;gt; ErrorScreen)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): useChatStream detects Vercel 503 -&amp;gt; ui.fatalError = vercel_unsupported&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(server): serve web/dist instead of mockup/ (MOCKUP_DIR -&amp;gt; WEB_DIST_DIR)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(deploy): vercel buildCommand + outputDirectory point to web/dist&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chore: remove legacy mockup/ vanilla-JS SPA&lt;/code&gt; — the mockup, six weeks after birth, was deleted in one commit&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;vercel_unsupported&lt;/code&gt; error is a specific kind: Vercel&amp;rsquo;s serverless functions can&amp;rsquo;t host long-lived SSE connections, so deploying the backend there produces a 503 on the chat route. The frontend detects it explicitly and shows a screen explaining the EC2-only constraint instead of a generic network error.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="theme-2--the-workflow-consolidates-to-five-gates"&gt;Theme 2 — The Workflow Consolidates to Five Gates
&lt;/h2&gt;&lt;p&gt;Through PR4, the workflow had three approval points — one after copy, one after scenario, one at the end of storyboard. May 21 introduced &lt;strong&gt;GATE 1 (key concept selection)&lt;/strong&gt; and made the gate count five.&lt;/p&gt;
&lt;h3 id="why-gate-1-needed-to-exist"&gt;Why GATE 1 needed to exist
&lt;/h3&gt;&lt;p&gt;The pre-existing flow ran &lt;code&gt;research → copy directly&lt;/code&gt;. The user submitted a brief, research happened, and then the copy stage emitted four parallel drafts (one each for 감성/직설/유머/하이브리드 angles). The problem: the copy drafts inherited the research agent&amp;rsquo;s interpretation of the brief without ever asking the user &lt;em&gt;which direction&lt;/em&gt; they wanted. Two days of revisions per project were going into pushing the copy stage back to a different angle the user hadn&amp;rsquo;t asked for.&lt;/p&gt;
&lt;p&gt;The fix was a new specialist between research and copy:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;research → [key_concept_planner produces 10 candidate concepts: 3-3-2-2 distribution]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; → GATE 1 (user picks one)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; → copy stage runs anchored to selected concept
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The 3-3-2-2 distribution (3 commercial, 3 emotional, 2 narrative, 2 conceptual) came from &lt;code&gt;CATEGORY_PLAN&lt;/code&gt;. A later refactor (&lt;code&gt;refactor(agents): derive key_concept distribution from CATEGORY_PLAN&lt;/code&gt;) made the distribution computed from the plan rather than hardcoded, so adjusting the mix in one place propagates.&lt;/p&gt;
&lt;h3 id="frontend--approvegatekeyconcept"&gt;Frontend — ApproveGateKeyConcept
&lt;/h3&gt;&lt;p&gt;The 10-card selection UI shipped as &lt;code&gt;ApproveGateKeyConcept&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add KeyConcept gate types&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): ApproveGateKeyConcept 10-card selection drawer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): wire GATE 1 key-concept gate into ApproveGate&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Backend pieces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(runtime): emit GATE 1 key-concept gate after research stage&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fix(runtime): only block key_concept_planner re-enqueue while job is in flight&lt;/code&gt; — prevented a race where a slow re-render dispatch was being filtered out&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(runtime): route selectedKeyConcept to copy and feedback re-entry&lt;/code&gt; — copy stage receives the selected concept&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(agents): copywriter_agent anchors copy to the selected key concept&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(runtime): persist key_concept_set artifacts&lt;/code&gt; + &lt;code&gt;record GATE 1 key-concept selections in diff_history&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="gate-3-concept-approval-and-gate-5-final-approval"&gt;GATE 3 (concept approval) and GATE 5 (final approval)
&lt;/h3&gt;&lt;p&gt;Two more gates filled the workflow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(runtime): add GATE 3 컨셉 승인 — two-phase scenario stage&lt;/code&gt; — scenario got split into concept-approval-then-full-scenario&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add GATE 5 (최종 승인) to the storyboard stage&lt;/code&gt; — the final approval before the project is considered done&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Combined with GATE 2 (copy) and GATE 4 (full scenario), this gave the canonical 5-gate flow that all the UI labels would refer to from this point on.&lt;/p&gt;
&lt;h3 id="gate_state--12-state-live-transitions"&gt;gate_state — 12-state live transitions
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;feat(runtime): wire gate_state 12-state live transitions&lt;/code&gt; split the gate lifecycle from job lifecycle. The 12 states encode every meaningful transition (pending → emitted → awaiting_user → user_approved → user_revising → rerunning → reapproved → &amp;hellip;), each surfaced to the frontend so the UI can render a meaningful &amp;ldquo;what&amp;rsquo;s happening with this gate&amp;rdquo; without polling for inferred state.&lt;/p&gt;
&lt;h3 id="the-planning-report-at-gate-1"&gt;The planning report at GATE 1
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;feat: add report_writer agent + 리서치 분석 보고서 at GATE 1&lt;/code&gt; added a second specialist that runs alongside key_concept_planner — it produces a structured 리서치 분석 보고서 (research analysis report) that gives the user the &lt;em&gt;context&lt;/em&gt; behind the 10 key concepts. Without this, the user is picking from concepts cold; with it, they see the underlying reasoning.&lt;/p&gt;
&lt;p&gt;Then a follow-up — &lt;code&gt;feat: add report_writer planning mode for 최종 기획 보고서&lt;/code&gt; — taught the same agent to produce a different &lt;em&gt;kind&lt;/em&gt; of report at the end of the workflow (a planning report summarizing the entire project).&lt;/p&gt;
&lt;h3 id="html-template-set"&gt;HTML template set
&lt;/h3&gt;&lt;p&gt;The reports needed to &lt;em&gt;look&lt;/em&gt; like documents, not chat bubbles. A whole HTML template set arrived in successive commits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat: add base.css slide canvas and primitives&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add storyboard cover and continuity-grid templates&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add deck.css and four simple deck templates&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add photo-caption and fullbleed-caption deck templates&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add annotated-photo, product-lineup, info-card, creative-board templates&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add report_writer HTML conversion design spec&lt;/code&gt; + &lt;code&gt;implementation plan&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add template gallery index&lt;/code&gt; + &lt;code&gt;LLM-facing template catalog&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The template catalog is the LLM-facing piece — the agent gets a structured list of templates to choose from when assembling a multi-page document. That&amp;rsquo;s how it can produce a storyboard cover, continuity grid, treatment grid, and conti sequence all from one prompt without hand-coding a layout per page.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="theme-3--approvebar-replaces-the-drawer-output-tabs-replace-the-inline-render"&gt;Theme 3 — ApproveBar Replaces the Drawer; Output Tabs Replace the Inline Render
&lt;/h2&gt;&lt;p&gt;By 2026-05-21 the bottom drawer pattern (&lt;code&gt;ApproveSheet&lt;/code&gt;) had become a UX problem: opening it covered the canvas, and the canvas was where the &lt;em&gt;result&lt;/em&gt; lived. The user had to keep collapsing the drawer to look at the storyboard they were approving.&lt;/p&gt;
&lt;p&gt;Three commits killed the drawer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add slim ApproveBar, replace ApproveGate in ChatPanel&lt;/code&gt; — a horizontal bar at the bottom of the chat panel&lt;/li&gt;
&lt;li&gt;&lt;code&gt;refactor(web): remove ApproveSheet/StageCanvas drawer stack&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): show output panel on active gate + scenario gate advances&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The ApproveBar is just two buttons (Approve / 수정요청) plus the gate context — minimal, no drawer to drag. The actual artifact to inspect lives in the canvas (right column), where it always did.&lt;/p&gt;
&lt;h3 id="output-tabs--canvas-pivots-from-show-current-stage-to-tabbed-history-of-all-stages"&gt;Output tabs — canvas pivots from &amp;ldquo;show current stage&amp;rdquo; to &amp;ldquo;tabbed history of all stages&amp;rdquo;
&lt;/h3&gt;&lt;p&gt;Right after the ApproveBar landed, the canvas itself was re-architected:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add output-tabs definitions and visibility logic&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add activeOutputTab to ui slice&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add output fields to pipeline slice&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): hydrate output slots from artifacts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): route gate/planning outputs into pipeline slice&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add analysis/keyConcept/planning/concept tab bodies&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add selectable copy tab body&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add OutputTabs container&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): swap CanvasPanel body to OutputTabs&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Previously the canvas only ever showed the &lt;em&gt;current&lt;/em&gt; stage&amp;rsquo;s artifact. Now it has tabs across the top — Analysis, Key Concept, Concept, Copy, Scenario, Storyboard — and the user can flip back to look at any prior artifact while still working on the current stage. That changes the UX entirely: the canvas became a project workspace, not just a current-state display.&lt;/p&gt;
&lt;h3 id="the-planner-generator-evaluator-loop"&gt;The Planner-Generator-Evaluator loop
&lt;/h3&gt;&lt;p&gt;The runtime equivalent of this UX move is the &lt;strong&gt;Planner-Generator-Evaluator loop&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat: Planner-Generator-Evaluator loop + BDI structure&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): register copy_evaluator in agent-copy map&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(runtime): extend the evaluator loop to the scenario stage (§3.4)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(runtime): extend the evaluator loop to the storyboard stage (§3.4)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fix(runtime): re-assess regenerations in the evaluator loop&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern: every generation stage now runs Planner → Generator → Evaluator. The Evaluator decides whether the output meets quality bars; if not, the loop re-runs Generator with feedback. The user sees the &lt;em&gt;final&lt;/em&gt; output after the loop converges (or hits a max iteration cap). This is also what made the output tabs valuable — the planning artifact and evaluation notes for each stage are visible if the user wants to dig in.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="theme-4--the-revise-mode-system"&gt;Theme 4 — The Revise-Mode System
&lt;/h2&gt;&lt;p&gt;This is the biggest UX commitment in this window. Before this work, &amp;ldquo;I want to redo just one card&amp;rdquo; meant &amp;ldquo;redo the whole stage&amp;rdquo; — the user had no way to surgically pin everything except the piece they wanted regenerated.&lt;/p&gt;
&lt;h3 id="intent-classification"&gt;Intent classification
&lt;/h3&gt;&lt;p&gt;The first piece (&lt;code&gt;feat: add revision-intent — classify chat revision as bulk/partial&lt;/code&gt;) is a small classifier on chat input. When the user types in a gate context, the system asks: is this a &lt;em&gt;bulk&lt;/em&gt; request (&amp;ldquo;make all the copy more emotional&amp;rdquo;) or a &lt;em&gt;partial&lt;/em&gt; request (&amp;ldquo;change just the third one&amp;rdquo;)? The output drives different runtime paths:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bulk&lt;/strong&gt; → re-run the entire stage with the feedback as context&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partial&lt;/strong&gt; → enter &amp;ldquo;revise mode&amp;rdquo; in the UI, let the user pick which items to regenerate&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// runtime/orchestration/revision-intent.js (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;classifyRevisionIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gateContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasPartialMarkers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;partial&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasBulkMarkers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;bulk&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ambiguous&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// UI nudges the user to clarify
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="revise-mode-ui"&gt;Revise-mode UI
&lt;/h3&gt;&lt;p&gt;The frontend gained a revise-mode toggle on the ApproveBar and multi-select checkboxes on every selectable tab:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat: pipeline store — reviseMode + reviseSelection state&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: add RevisionContext type + ChatContext.revision field&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: ApproveBar revise-mode toggle with selection count&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: KeyConceptTab multi-select checkboxes in revise mode&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: CopyTab multi-select checkboxes in revise mode&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: ScenarioCanvas cut-level revise checkboxes&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: StoryboardCanvas panel-level revise checkboxes&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;reviseSelection&lt;/code&gt; is a &lt;code&gt;Set&amp;lt;id&amp;gt;&lt;/code&gt; per artifact kind. The user toggles individual cards; the count appears live in the ApproveBar so they always know how many items will be regenerated.&lt;/p&gt;
&lt;h3 id="revision-merge--the-index-splice"&gt;Revision-merge — the index splice
&lt;/h3&gt;&lt;p&gt;The runtime side needed a way to actually &lt;em&gt;merge&lt;/em&gt; the regenerated subset back into the existing set. That&amp;rsquo;s the &lt;code&gt;revision-merge&lt;/code&gt; module:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// runtime/orchestration/revision-merge.js (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;mergeRevision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;regeneratedSubset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;selectedIndices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;originalSet&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;regeneratedSubset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;newItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selectedIndices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newItem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Pure function — splice at the indices that were selected, leave the rest frozen. Tested end-to-end: &lt;code&gt;test: end-to-end partial revision keeps frozen items + 3-3-2-2&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="each-agent-learned-partial-regeneration"&gt;Each agent learned partial regeneration
&lt;/h3&gt;&lt;p&gt;Five specialist agents learned to regenerate only the selected slots:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat: key_concept_planner regenerates selected slots (category-preserving)&lt;/code&gt; — preserves the 3-3-2-2 distribution&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: copywriter_agent regenerates selected copy drafts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: concept_anchor applies revision feedback (GATE 3, whole-doc)&lt;/code&gt; — GATE 3 is whole-doc only because concept anchor is itself a single artifact&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: scene_designer cut-level revision (structure + cut count preserved)&lt;/code&gt; — preserves total cut count&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: storyboard_generator regenerates selected panels + images&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each agent had to know what its frozen-item constraints were. Storyboard could regenerate just panel 3 without recomputing the cut count, but scene_designer had to preserve total cut count because the scenario&amp;rsquo;s structural contract is the cut count.&lt;/p&gt;
&lt;h3 id="routing-layer"&gt;Routing layer
&lt;/h3&gt;&lt;p&gt;Two routing commits put it together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat: root-planner routes context.revision to single target agent&lt;/code&gt; — backend only enqueues the one specialist needed&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: route chat revision intent — bulk runs, partial nudges revise mode&lt;/code&gt; — frontend dispatches to the right path&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat: dispatch revise_mode_hint — open revise mode on partial chat intent&lt;/code&gt; — backend can &lt;em&gt;suggest&lt;/em&gt; revise mode to the frontend via a hint event, so a partial intent auto-opens the UI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The hint event was a careful boundary — backend never forces the frontend; it suggests. The frontend can override if its local state has reason to.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="theme-5--session-isolation-multi-attempt-per-project"&gt;Theme 5 — Session Isolation (multi-attempt per project)
&lt;/h2&gt;&lt;p&gt;On 2026-05-22 the workspace went from one-session-per-project to multi-session-per-project. A project can now hold parallel attempts — a single creative brief might be tried three different ways, each as its own session, with their own pipeline state.&lt;/p&gt;
&lt;p&gt;Key commits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Isolate workspace sessions and restore state per session&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;server: project sessions list endpoint&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;web: session restore and run recovery&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Start a fresh pipeline for a new session's first brief&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;launcher: show per-session states on project cards&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;launcher/workspace: deletable sessions + per-session gate&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;web: scope workspace canvas to the active session&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;web: honor session routes and preserve revision scopes&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The launcher card now shows per-session gates — a project that had three sessions, two at storyboard and one at copy, renders all three states. Sessions can be deleted individually. The workspace canvas, the revise mode, and the gate state all scope to the active session.&lt;/p&gt;
&lt;p&gt;The biggest invisible commit: &lt;code&gt;Lock the session after final storyboard approval&lt;/code&gt;. Once GATE 5 is approved, the session is read-only. This prevents accidental edits to a sealed deliverable, and forces the user to consciously start a new session if they want to iterate further.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;Four themes in three days, but the recurring shape is the same: &lt;strong&gt;collapse of &amp;ldquo;all-or-nothing&amp;rdquo; into something granular.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The drawer-or-canvas binary became the output-tabs continuum (you can see every artifact at once)&lt;/li&gt;
&lt;li&gt;The three gates became five (with the new gate guarding the most expensive downstream cost: copy + scenario + storyboard run on a chosen direction)&lt;/li&gt;
&lt;li&gt;The whole-stage-re-run became partial revision (you regenerate exactly what you want)&lt;/li&gt;
&lt;li&gt;The one-session-per-project became multi-session-per-project (a project can hold parallel ideas)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these moves cost more code than the binary version would have — partial revision alone required intent classification, frozen-item preservation logic in five agents, a Set-of-IDs in the store, and an index-splice merge function — but each one removed a class of frustration the user had been quietly bearing. The 153-commit count looks heroic; the through-line is more modest: every time the system said &amp;ldquo;all or nothing,&amp;rdquo; replace it with &amp;ldquo;exactly what you meant.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Next: polish week — observability instrumentation, per-project state isolation under refresh, inline past-gate rewind from chat, and the revision-request affordance pass that finally unified the visual treatment across every tab.&lt;/p&gt;</description></item><item><title>Creative Agent Studio #3 — The 134-Commit Megapush: PR1 Launcher, PR2 Workspace, PR3 SSE, PR4 ApproveGate</title><link>https://ice-ice-bear.github.io/posts/2026-05-19-creative-agent-studio-dev3/</link><pubDate>Tue, 19 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-19-creative-agent-studio-dev3/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post Creative Agent Studio #3 — The 134-Commit Megapush: PR1 Launcher, PR2 Workspace, PR3 SSE, PR4 ApproveGate" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-05-18-creative-agent-studio-dev2/" &gt;Previous post: #2 — Bitbucket migration, production-readying, React rewrite begins&lt;/a&gt; ended with the React infrastructure bootstrapped: empty Zustand store, Vite + Tailwind + TS, three primitives, a &lt;code&gt;&amp;lt;T&amp;gt;&lt;/code&gt; i18n component, and a PR1 plan. Twenty-four hours later, &lt;strong&gt;134 non-merge commits&lt;/strong&gt; had landed four PRs in sequence and produced a working React frontend with real database persistence and live SSE streaming.&lt;/p&gt;
&lt;p&gt;The work fell into four PRs and one stack underneath:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PR1 — Launcher&lt;/strong&gt; (projects CRUD, ProjectCard, NewProjectCard, CreateProjectModal, ProjectGrid)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR2 — Workspace shell&lt;/strong&gt; (StageStepper, ProjectSubbar, SessionsRail, Composer, ChatPanel, CanvasPanel placeholder)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR3 — SSE + chat flow&lt;/strong&gt; (chat-stream parser, dispatch-sse mapper, useChatStream hook, AgentAvatar, all 7 FeedItem variants, ChatFeed)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PR4 — ApproveSheet + Gates&lt;/strong&gt; (3-mode drawer, ApproveGateCopy / Scenario / ResearchInput variants)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Underneath&lt;/strong&gt; — &lt;code&gt;projects&lt;/code&gt;, &lt;code&gt;artifacts&lt;/code&gt;, &lt;code&gt;diff_history&lt;/code&gt; tables; CRUD operations; full REST API; soft-delete semantics&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Prev["#2 — bootstrap (752095f)"] --&gt; DB["DB layer &amp;lt;br/&amp;gt; migrations + CRUD + REST"]
 DB --&gt; PR1["PR1 — Launcher &amp;lt;br/&amp;gt; projects survive reload"]
 PR1 --&gt; PR2["PR2 — Workspace shell &amp;lt;br/&amp;gt; StageStepper + SessionsRail + Composer"]
 PR2 --&gt; PR3["PR3 — SSE chat flow &amp;lt;br/&amp;gt; chat-stream + dispatch-sse + 7 feed variants"]
 PR3 --&gt; PR4["PR4 — ApproveSheet &amp;lt;br/&amp;gt; 3-mode drawer + 3 gate variants"]
 PR4 --&gt; End["#3 end (9a2f851) &amp;lt;br/&amp;gt; restore project gate from artifacts"]&lt;/pre&gt;&lt;p&gt;One running theme — &lt;strong&gt;build the surface bottom-up, and never let a commit cross a layer it shouldn&amp;rsquo;t.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="the-database-layer--same-day-as-the-ui"&gt;The Database Layer — Same Day as the UI
&lt;/h2&gt;&lt;p&gt;Three migrations and a small handful of CRUD modules made &lt;code&gt;projects&lt;/code&gt;, &lt;code&gt;artifacts&lt;/code&gt;, and &lt;code&gt;diff_history&lt;/code&gt; first-class. The first commit (&lt;code&gt;feat(db): add projects, artifacts, diff_history tables via migrations&lt;/code&gt;) set the shape:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- which gate the project last paused at
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- soft delete
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;artifacts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- JSON
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;evidence_uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- §8.2 — provenance
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff_history&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;before&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- JSON (nullable for first selection)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- JSON
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Three commits later, the CRUD module landed: &lt;code&gt;createProject&lt;/code&gt;, &lt;code&gt;listProjects&lt;/code&gt; (active only, updated_at desc), &lt;code&gt;getProject&lt;/code&gt; (returns deleted rows for audit), &lt;code&gt;renameProject&lt;/code&gt; (partial patch semantics), &lt;code&gt;softDeleteProject&lt;/code&gt; (idempotent — no double-flip of &lt;code&gt;deleted_at&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The idempotency rule was specifically tested in &lt;code&gt;fix(db): softDeleteProject preserves deleted_at on idempotent re-call&lt;/code&gt;. The original implementation called &lt;code&gt;UPDATE ... SET deleted_at = ?&lt;/code&gt; which would overwrite an earlier deletion timestamp if called twice. The fix: &lt;code&gt;UPDATE ... SET deleted_at = ? WHERE deleted_at IS NULL&lt;/code&gt;. Small bug, larger principle — soft-delete is a tombstone, not a flag.&lt;/p&gt;
&lt;p&gt;Then the REST API came up on top in five commits:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /api/projects → createProject
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /api/projects → listProjects
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;GET /api/projects/:id → getProject (404 if deleted — fix(api))
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PATCH /api/projects/:id → renameProject
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;DELETE /api/projects/:id → softDeleteProject
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A separate fix made GET and PATCH treat soft-deleted projects as 404 (audit-visible via the lower-level CRUD path, but invisible at the REST surface). The diff_history module added &lt;code&gt;appendDiff&lt;/code&gt; with a guard against storing literal &lt;code&gt;'null'&lt;/code&gt; strings when &lt;code&gt;after&lt;/code&gt; was &lt;code&gt;null&lt;/code&gt; — the kind of bug that&amp;rsquo;s only obvious when you find someone&amp;rsquo;s project history showing the string &lt;code&gt;&amp;quot;null&amp;quot;&lt;/code&gt; rendered in the UI.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr1--the-launcher"&gt;PR1 — The Launcher
&lt;/h2&gt;&lt;p&gt;The user&amp;rsquo;s first screen. Project list, create-new affordance, search.&lt;/p&gt;
&lt;p&gt;A handful of structural commits set up the page architecture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): real projects slice (CRUD)&lt;/code&gt; — &lt;code&gt;useProjects&lt;/code&gt; hook with sort-by-updated&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add userName + setUserName to ui slice&lt;/code&gt; — persisted user name shown in the bilingual greeting&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AppTopbar with logo + lang toggle&lt;/code&gt; — site chrome&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add LauncherTopbar + LauncherHero with bilingual greeting&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add SessionsRail hidden-mode stub (PR2 fills workspace mode)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the actual list and creation flow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ProjectsSearch with bilingual placeholder and count kicker&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ProjectCard with progress bar and brand/status&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add NewProjectCard with dashed border and accent hover&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add CreateProjectModal with title + optional brand + validation&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): wire ProjectGrid — search filter, modal, navigation&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The card progress bar reads from the same &lt;code&gt;gate&lt;/code&gt; field the project advanced to — so the launcher card is a live snapshot of how far along each project is. Click → navigate to &lt;code&gt;/projects/:projectId&lt;/code&gt;. Done.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s clean about this PR is its strict layering: every interactive piece reads from the store, the store reads from the REST API, the REST API reads from the CRUD module, the CRUD module reads from SQLite. No shortcuts. The component itself only knows about the slice.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr2--the-workspace-shell"&gt;PR2 — The Workspace Shell
&lt;/h2&gt;&lt;p&gt;The screen users land on after clicking a project card. Three columns: sessions rail on the left, chat in the middle, canvas placeholder on the right. The actual stage-by-stage workflow lives inside this shell.&lt;/p&gt;
&lt;p&gt;Eleven commits, structural:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add stage-labels lib (gate ↔ stage + ko/en short/long labels)&lt;/code&gt; — the canonical mapping of stages to their bilingual labels, used by both the stepper and the canvas&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add Session type&lt;/code&gt; + &lt;code&gt;real workspace slice (sessions + composer)&lt;/code&gt; + &lt;code&gt;store-init test&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add sessionsPanelOpen + toggleSessionsPanel to ui slice&lt;/code&gt; — the rail collapses&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add StageStepper (6 dots + bilingual long-form label)&lt;/code&gt; — the stage progress indicator&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ProjectSubbar wrapping StageStepper&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): fill SessionsRail workspace mode (project header + sessions list + new + back)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add Composer (pill input + send button, console-log submit only)&lt;/code&gt; — chat input, no submission wiring yet&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ChatPanel skeleton (header + welcome bubble + Composer)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add CanvasPanel placeholder (PR5 fills variants)&lt;/code&gt; — explicit deferral&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): assemble WorkspacePage (.shell grid) + swap workspace route&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The placeholder pattern is worth noticing. &lt;code&gt;CanvasPanel&lt;/code&gt; was added as a placeholder that explicitly said &amp;ldquo;PR5 fills variants&amp;rdquo; in the commit message. That deferred a large piece of work without leaving a hole in the layout — the layout was done, the slot was wired, the actual content was a stub that wouldn&amp;rsquo;t ship to users yet. PR5 could land cleanly because PR2 had reserved its address.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr3--sse-plumbing-chat-flow-and-the-seven-feed-variants"&gt;PR3 — SSE Plumbing, Chat Flow, and the Seven Feed Variants
&lt;/h2&gt;&lt;p&gt;This is the PR that turned a static shell into a live app. Twenty-something commits across three layers — store, plumbing, components.&lt;/p&gt;
&lt;h3 id="store-layer"&gt;Store layer
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/store/slices/feed.ts (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FeedItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;user&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;assistant&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;streaming&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;agent_progress&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;stage_complete&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;system_error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;task_update_note&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FeedSlice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;feed&lt;/span&gt;: &lt;span class="kt"&gt;FeedItem&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;appendChunk&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// streaming
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;splitParagraphs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// streaming → final
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;pushAgentProgress&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;pushStageComplete&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A discriminated union for the seven feed item types, with per-kind reducers on the slice. Plus a &lt;code&gt;pipeline&lt;/code&gt; slice for the gate/stage/context/lastSubmit state, and a &lt;code&gt;workspace&lt;/code&gt; slice that gained &lt;code&gt;tasks&lt;/code&gt; + &lt;code&gt;activeSubAgents&lt;/code&gt; (sink for &lt;code&gt;agent_activity&lt;/code&gt; envelopes).&lt;/p&gt;
&lt;h3 id="plumbing-layer"&gt;Plumbing layer
&lt;/h3&gt;&lt;p&gt;Three pure modules and one hook:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add chat-stream lib (POST /api/chat + SSE parse + Zod-validated envelopes)&lt;/code&gt; — the wire-level parser&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add dispatch-sse mapper (SSEEnvelope → store actions)&lt;/code&gt; — pure mapper, no DOM&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add useChatStream hook (submit/cancel + AbortController lifecycle)&lt;/code&gt; — lifecycle owner&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/hooks/use-chat-stream.ts (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;useChatStream() {&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controllerRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;AbortController&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="na"&gt;null&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;: &lt;span class="kt"&gt;ChatContext&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;controllerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;controllerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;streamChat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;: &lt;span class="kt"&gt;controller.signal&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;dispatchSse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// store action
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useCallback&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;controllerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cancel&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The split was: chat-stream parses, dispatch-sse maps, useChatStream owns the lifecycle. Three things, three modules — no module ever did more than one job. Tests for each (&lt;code&gt;tighten test fetch types&lt;/code&gt; etc) shipped on the same day.&lt;/p&gt;
&lt;h3 id="component-layer"&gt;Component layer
&lt;/h3&gt;&lt;p&gt;Once envelopes could land in the store, the components rendered them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AgentAvatar + fix shared/events runtime exports&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add User/Assistant/Streaming bubble components&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AgentProgress + StageComplete + SystemError + TaskUpdateNote&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add FeedItem discriminated dispatcher (7 variants)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add AgentStrip ('Running now' active sub-agents)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ChatFeed (auto-scroll + FeedItem map + welcome empty state)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): wire ChatPanel to AgentStrip + ChatFeed (replaces static welcome bubble)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;FeedItem&lt;/code&gt; dispatcher is the discriminated-union pattern paying off — one place that maps every variant to its component, with exhaustiveness enforced by TS.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;AgentStrip&lt;/code&gt; was the materialization of decision 2 from &lt;code&gt;interaction-model.md&lt;/code&gt; — &amp;ldquo;one-line status, no full dashboard.&amp;rdquo; It shows only the currently-running sub-agents as a horizontal strip across the top of the chat. Not a side panel, not a dashboard, just an inline strip that appears when work is happening and disappears when it isn&amp;rsquo;t.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pr4--the-approvesheet-and-the-three-gate-variants"&gt;PR4 — The ApproveSheet and the Three Gate Variants
&lt;/h2&gt;&lt;p&gt;The piece that turned the chat-only flow into a chat + approval flow.&lt;/p&gt;
&lt;p&gt;The ApproveSheet is a bottom drawer with three modes — collapsed (just the gate prompt), half-open (gate + summary), full (gate + everything). Drag-to-resize via pointer events. Esc collapses. Three gate variants drop into the same shell — each gate stage gets its own variant component.&lt;/p&gt;
&lt;p&gt;Twelve commits, structural again:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGate payload types&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add approveSheetMode + setApproveSheetMode to ui slice&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): pipeline slice gains copy/scenario history + idx setters&lt;/code&gt; — the back-pointer state for switching between historical drafts&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): projects slice gains bumpProjectGate + assert PR4 fields in store-init&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): dispatch-sse pushes copy/scenario history + bumps project gate on stage_complete&lt;/code&gt; — the SSE → store glue for gate advance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the drawer mechanics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add useApproveSheetDrag hook + PointerEvent jsdom polyfill&lt;/code&gt; — the drag-handle hook. PointerEvent isn&amp;rsquo;t in jsdom by default, so a polyfill shipped alongside.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveSheet base (3-mode drawer + collapse toggle + drag handle)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the three gate variants:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGateCopy (grid + select + approve / request-edit)&lt;/code&gt; — the user picks one of N copy drafts&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGateScenario (acts list + approve / request-edit)&lt;/code&gt; — the user approves or revises a multi-act scenario&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feat(web): add ApproveGateResearchInput (question + answer + send)&lt;/code&gt; — a different shape: the agent asks the user a research question and waits for a typed answer&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wired together by &lt;code&gt;ApproveGate&lt;/code&gt; dispatcher (variant selection + submit wiring), then mounted inside &lt;code&gt;ChatPanel&lt;/code&gt; with submit forwarded from &lt;code&gt;WorkspacePage&lt;/code&gt;. The user&amp;rsquo;s flow is now: ask in chat → agent runs → drawer rises with the artifact → approve or revise. Decision 3 from &lt;code&gt;interaction-model.md&lt;/code&gt; (gate-based auto-run between stages) is alive.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="the-end-of-day-state"&gt;The End-of-Day State
&lt;/h2&gt;&lt;p&gt;The last commit of the day (&lt;code&gt;9a2f851 feat(web): restore project gate from artifacts on workspace mount (Slice K)&lt;/code&gt;) closed an important loop: when the user reloads the workspace page, the project&amp;rsquo;s gate state is restored from its persisted artifacts. The day&amp;rsquo;s work survives a refresh.&lt;/p&gt;
&lt;p&gt;Between the underneath stack and PR1–PR4, the React frontend at end-of-day had:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A working launcher (real projects, persisted across reloads)&lt;/li&gt;
&lt;li&gt;A workspace shell with a three-column layout&lt;/li&gt;
&lt;li&gt;Live chat with SSE streaming&lt;/li&gt;
&lt;li&gt;All 7 feed variants rendering&lt;/li&gt;
&lt;li&gt;An ApproveSheet drawer with 3 gate variants&lt;/li&gt;
&lt;li&gt;Project state surviving reload&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mockup wasn&amp;rsquo;t deleted yet, but everything it was supposed to demonstrate now ran in React.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;The day worked because every commit respected its layer. The store slices were declared in PR2 with the shape they&amp;rsquo;d need for PR4. The SSE plumbing in PR3 was three modules — parser, mapper, lifecycle — that could each be tested independently. The ApproveGate variants in PR4 plugged into a dispatcher pattern matching the FeedItem dispatcher from PR3.&lt;/p&gt;
&lt;p&gt;The thing that didn&amp;rsquo;t happen — and was the reason 134 commits in a day was not chaos — was &lt;em&gt;cross-layer commits&lt;/em&gt;. No commit added a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; and a SQL column at the same time. No commit reached up from a CRUD module into a slice. When a commit needed to span layers, it was either preceded by a foundation commit one layer below or followed by an adopter commit one layer above. The discipline turned what would otherwise be a sprint of patchwork into a clean stack of additions.&lt;/p&gt;
&lt;p&gt;The mental model worth keeping: &lt;strong&gt;fast does not mean shortcut.&lt;/strong&gt; This day shipped four PRs because of the discipline that let each PR exist, not in spite of it.&lt;/p&gt;
&lt;p&gt;Next: the canvas panel, the 5-gate workflow, the key-concept planner, and the revise-mode multi-select pattern that would let users surgically re-run a single card instead of the whole stage.&lt;/p&gt;</description></item><item><title>Creative Agent Studio #2 — Bitbucket Migration, Production-Readying, and the React Rewrite Begins</title><link>https://ice-ice-bear.github.io/posts/2026-05-18-creative-agent-studio-dev2/</link><pubDate>Mon, 18 May 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-05-18-creative-agent-studio-dev2/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post Creative Agent Studio #2 — Bitbucket Migration, Production-Readying, and the React Rewrite Begins" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-07-creative-agent-studio-dev1/" &gt;Previous post: #1 — the mockup era and Creative Warmth theme&lt;/a&gt; shipped six weeks ago. After a long quiet stretch, 2026-05-18 detonated with &lt;strong&gt;27 commits in a single day&lt;/strong&gt; — and they fell into three clean stacks. First, a bitbucket migration snapshot to mark the repo&amp;rsquo;s relocation. Second, &lt;strong&gt;a production-readying pass on the Node runtime&lt;/strong&gt; — structured JSON logging, graceful worker shutdown via &lt;code&gt;AbortSignal&lt;/code&gt;, &lt;code&gt;/api/health&lt;/code&gt; + &lt;code&gt;/api/metrics&lt;/code&gt;, single-EC2 deploy assets (systemd, nginx, litestream), and three refactors splitting the monolithic queue. Third, &lt;strong&gt;the React+Vite+TypeScript bootstrap that would replace the mockup entirely within 72 hours&lt;/strong&gt; — Tailwind with Creative Warmth tokens carried over verbatim from April, a Zustand store with 5 empty slices, the &lt;code&gt;&amp;lt;T&amp;gt;&lt;/code&gt; i18n component, and the first design primitives.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Prev["#1 — April mockup &amp;lt;br/&amp;gt; (21f0ebc)"] --&gt; Stack1["Stack 1 &amp;lt;br/&amp;gt; bitbucket migration snapshot"]
 Prev --&gt; Stack2["Stack 2 &amp;lt;br/&amp;gt; runtime hardening &amp;lt;br/&amp;gt; logger / shutdown / health / deploy"]
 Prev --&gt; Stack3["Stack 3 &amp;lt;br/&amp;gt; web/ bootstrap &amp;lt;br/&amp;gt; React + Vite + Tailwind + Zustand"]
 Stack1 --&gt; End["#2 end &amp;lt;br/&amp;gt; (752095f)"]
 Stack2 --&gt; End
 Stack3 --&gt; End&lt;/pre&gt;&lt;p&gt;The day&amp;rsquo;s running theme: &lt;strong&gt;stop treating this as a prototype.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="stack-1--the-bitbucket-migration-snapshot"&gt;Stack 1 — The Bitbucket Migration Snapshot
&lt;/h2&gt;&lt;p&gt;The first commit of the day (&lt;code&gt;9d414f2 chore: snapshot project state for bitbucket migration&lt;/code&gt;) wasn&amp;rsquo;t a code change — it was a marker. Repos move for reasons beyond engineering, and the snapshot commit made the cutover legible: this is the state shipped from the old home, and everything after is the new home.&lt;/p&gt;
&lt;p&gt;Worth noting because future &amp;ldquo;what changed?&amp;rdquo; investigations can stop at this commit when looking for &amp;ldquo;code that survived the move&amp;rdquo; vs &amp;ldquo;code added after.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="stack-2--production-readying-the-runtime"&gt;Stack 2 — Production-Readying the Runtime
&lt;/h2&gt;&lt;h3 id="structured-json-logger--adoption"&gt;Structured JSON logger + adoption
&lt;/h3&gt;&lt;p&gt;Commits &lt;code&gt;ead6854&lt;/code&gt; and &lt;code&gt;d0b9ea4&lt;/code&gt; introduced a structured JSON logger and adopted it in the chat route + worker lifecycle paths. The pre-existing pattern was &lt;code&gt;console.log&lt;/code&gt; strings, which would not survive an actual deploy to a log aggregator.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// runtime/observability/logger.js (paraphrased shape)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;createLogger&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;info&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;warn&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The contract was deliberately minimal — one severity, one message string, one context object. The chat route was the first adopter because that&amp;rsquo;s where SSE frames are emitted, and any unstructured &lt;code&gt;console.log&lt;/code&gt; there would interleave with frame headers in stdout and corrupt logs.&lt;/p&gt;
&lt;h3 id="graceful-worker-shutdown-via-abortsignal"&gt;Graceful worker shutdown via AbortSignal
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;d9bf640&lt;/code&gt;. The pre-existing worker loop was an infinite &lt;code&gt;while (true)&lt;/code&gt; claim-and-run. &lt;code&gt;kill -TERM&lt;/code&gt; on the worker process would kill it mid-job, leaving the SQLite &lt;code&gt;jobs&lt;/code&gt; row in &lt;code&gt;running&lt;/code&gt; state with a stale &lt;code&gt;worker_lock&lt;/code&gt; — eventually reclaimed by the stale-timeout mechanism, but with a delay measured in minutes.&lt;/p&gt;
&lt;p&gt;The fix wired an &lt;code&gt;AbortSignal&lt;/code&gt; through the loop:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// runtime/workers/worker-loop.js
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;startWorkerLoop&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;claimNextJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;POLL_INTERVAL_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;runJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;releaseJobForReclaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// explicit unlock
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Combined with a &lt;code&gt;SIGTERM&lt;/code&gt; handler in &lt;code&gt;server/index.js&lt;/code&gt; that calls &lt;code&gt;controller.abort()&lt;/code&gt; and waits for workers to settle, this turns a 5-minute reclaim window into an immediate clean shutdown — important for any rolling deploy.&lt;/p&gt;
&lt;h3 id="daily-maintenance--apihealth--apimetrics"&gt;Daily maintenance + /api/health + /api/metrics
&lt;/h3&gt;&lt;p&gt;Three more commits closed the operational gaps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;3ac2dfa&lt;/code&gt; — periodic event-log retention. The &lt;code&gt;events&lt;/code&gt; table grows monotonically as SSE frames are persisted. Without retention, a long-running deploy fills disk. Cron-like job runs daily, deletes events older than N days.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;89c7b05&lt;/code&gt; — &lt;code&gt;/api/health&lt;/code&gt; (boot probe) and &lt;code&gt;/api/metrics&lt;/code&gt; (Prometheus-style counters). A load balancer can now do its job, and a Grafana dashboard can finally exist.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;d0b9ea4&lt;/code&gt; — wired all three (shutdown, maintenance, logger) into &lt;code&gt;server/index.js&lt;/code&gt; boot.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Grafana setup itself got its own doc (&lt;code&gt;4236122 docs: add Grafana Cloud setup guide for EC2 deployment&lt;/code&gt;) because the metric set was small enough to deploy once and forget — the runtime&amp;rsquo;s only telemetry concerns were queue depth, job duration, and gate-time-to-approval.&lt;/p&gt;
&lt;h3 id="single-ec2-deploy-assets"&gt;Single-EC2 deploy assets
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;9ac967f&lt;/code&gt; added the entire single-EC2 deploy stack: systemd unit files for the Node server + workers, an nginx config that terminates TLS and proxies to &lt;code&gt;:7878&lt;/code&gt;, and a Litestream configuration that replicates &lt;code&gt;data/runtime.sqlite&lt;/code&gt; to S3 continuously. Litestream is the linchpin — it makes SQLite a defensible production choice for a small-team app by giving you continuous point-in-time backup without changing application code.&lt;/p&gt;
&lt;h3 id="runtime-refactors"&gt;Runtime refactors
&lt;/h3&gt;&lt;p&gt;Three companion refactors made the codebase ready for the upcoming PR0–PR4 spree:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1cf3bce refactor: split runtime/queue/jobs.js into runs/jobs/events modules&lt;/code&gt; — &lt;code&gt;jobs.js&lt;/code&gt; had become a god-module conflating run lifecycle, job claiming, and event persistence. Split into three single-responsibility modules.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4ee661e refactor: extract stage state machine + route events through persistEvent&lt;/code&gt; — the &amp;ldquo;what stage is the run in, and what can transition out of it&amp;rdquo; logic was sprinkled across the worker. Extracted into a single state machine module; all event writes now route through &lt;code&gt;persistEvent&lt;/code&gt; so a missing event can&amp;rsquo;t be a silent bug.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c7291af refactor: consolidate Google AI SDK on @google/genai&lt;/code&gt; — the codebase had been straddling two Google SDK packages. Consolidated on &lt;code&gt;@google/genai&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The runtime SQLite was also relocated to &lt;code&gt;data/&lt;/code&gt; (&lt;code&gt;2e3ac8a&lt;/code&gt;) so the &lt;code&gt;.gitignore&lt;/code&gt;-by-intent file could live in a dedicated subdir alongside the WAL/SHM journals.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="stack-3--bootstrapping-the-react-rewrite"&gt;Stack 3 — Bootstrapping the React Rewrite
&lt;/h2&gt;&lt;p&gt;The mockup&amp;rsquo;s days were numbered. A design spec (&lt;code&gt;253f83d docs: add design spec for mockup → web/ (React + Vite + TS) rebuild&lt;/code&gt;) and an implementation plan (&lt;code&gt;c81b248 docs: add PR0 implementation plan for web/ infra bootstrap&lt;/code&gt;) declared the target. Twenty commits then materialized it.&lt;/p&gt;
&lt;h3 id="package--tooling"&gt;Package + tooling
&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;web/package.json React 18 + Vite + TypeScript
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;web/tsconfig.{json,app.json,node.json} Vite-standard 3-file split
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;web/vite.config.ts /api proxy → :7878 (matches Express port)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;web/tailwind.config.ts Creative Warmth tokens
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;web/vitest.config.ts jsdom + RTL setup
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Tailwind config (&lt;code&gt;17aecd9&lt;/code&gt;) is worth calling out — every Creative Warmth token from April&amp;rsquo;s mockup CSS was hand-translated into Tailwind extensions:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/tailwind.config.ts (paraphrased)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;extend&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;warm-white&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;#FAF7F2&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// body bg
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;warm-paper&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;#F5F1EA&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// raised surfaces
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;text-primary&amp;#39;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;#2A2622&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// not pure black
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fontFamily&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#34;DM Serif Display&amp;#34;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;serif&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;hand&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Caveat&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;cursive&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Zero design tokens were re-derived; they were lifted verbatim. The mockup was throwaway code, but the design system was load-bearing.&lt;/p&gt;
&lt;h3 id="store-scaffold"&gt;Store scaffold
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;0b469b8 feat(web): scaffold Zustand store with 5 empty slices&lt;/code&gt; set up the state architecture before any feature code:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// web/src/store/index.ts
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UiSlice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ProjectsSlice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;WorkspaceSlice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;FeedSlice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;PipelineSlice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()((&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;createUiSlice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;createProjectsSlice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;createWorkspaceSlice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;createFeedSlice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;createPipelineSlice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;set&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Five slices — &lt;code&gt;ui&lt;/code&gt;, &lt;code&gt;projects&lt;/code&gt;, &lt;code&gt;workspace&lt;/code&gt;, &lt;code&gt;feed&lt;/code&gt;, &lt;code&gt;pipeline&lt;/code&gt; — declared &lt;em&gt;empty&lt;/em&gt; on day 1, with a test (&lt;code&gt;9543922 test(web): assert store init exposes all five slices&lt;/code&gt;) that locked the shape so subsequent commits could add fields without forgetting a slice. That decision would pay back across the next three days when 100+ commits added fields to those slices in parallel.&lt;/p&gt;
&lt;h3 id="the-t-i18n-component"&gt;The &lt;code&gt;&amp;lt;T&amp;gt;&lt;/code&gt; i18n component
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;24ff23e feat(web): add &amp;lt;T&amp;gt; i18n component with KO/EN toggle + tests&lt;/code&gt;. The app&amp;rsquo;s primary language is Korean, but launching with English support up front meant the bilingual prop pattern was established before any component was written:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;T&lt;/span&gt; &lt;span class="na"&gt;ko&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;키 컨셉을 선택하세요&amp;#34;&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Select a key concept&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A small choice — two props vs. an i18n key lookup — but it kept the source-of-truth co-located with the consumer. For a single-developer creative tool, that beat any framework-level i18n abstraction.&lt;/p&gt;
&lt;h3 id="design-primitives"&gt;Design primitives
&lt;/h3&gt;&lt;p&gt;Three primitives shipped on day 1 to anchor everything that came after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bf3ebaa feat(web): add cn() helper + minimal Button primitive&lt;/code&gt; — the &lt;code&gt;cn()&lt;/code&gt; utility for conditional classnames + a Button that already wore Creative Warmth.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a0cc67e feat(web): add Radix-backed Dialog primitive with warm scrim&lt;/code&gt; — Radix for accessibility, custom warm scrim (&lt;code&gt;rgba(42, 38, 34, 0.4)&lt;/code&gt; — translucent of &lt;code&gt;text-primary&lt;/code&gt;, not pure black) to match the design language.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;c2ee60f feat(web): add Input primitive with Creative Warmth styling&lt;/code&gt; — input field with the soft-edged warm-paper background.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="shared-event-schemas"&gt;Shared event schemas
&lt;/h3&gt;&lt;p&gt;Commit &lt;code&gt;0c37093 feat(shared): add SSE event Zod schemas + type re-exports&lt;/code&gt; — moved the SSE event types into &lt;code&gt;shared/events/index.mjs&lt;/code&gt; so backend (Express) and frontend (React) could import the same Zod schema. Type-safe SSE on both ends, validated at the parser layer.&lt;/p&gt;
&lt;h3 id="the-pr1-prelude"&gt;The PR1 prelude
&lt;/h3&gt;&lt;p&gt;The day ended with the PR1 planning docs (&lt;code&gt;558e7ec docs: add PR1 (Launcher) implementation plan&lt;/code&gt;) and the first launcher building blocks: a &lt;code&gt;Project&lt;/code&gt; type, filter/sort/progress helpers (&lt;code&gt;752095f&lt;/code&gt;). The Launcher page itself — the screen users would see first — was implementation tomorrow.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="commit-log-selected-27-total"&gt;Commit Log (selected, 27 total)
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Stack&lt;/th&gt;
 &lt;th&gt;Commits&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Bitbucket migration&lt;/td&gt;
 &lt;td&gt;snapshot project state&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Runtime hardening&lt;/td&gt;
 &lt;td&gt;structured JSON logger; AbortSignal shutdown; event-log retention; /api/health + /api/metrics; wire all three at boot&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Deploy stack&lt;/td&gt;
 &lt;td&gt;single-EC2 systemd/nginx/litestream; Node ≥ 22.5 engines field; relocate SQLite to data/; consolidate @google/genai&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Refactor&lt;/td&gt;
 &lt;td&gt;split queue/jobs into runs/jobs/events; extract stage state machine; rename agents to snake_case; archive April plans&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Docs&lt;/td&gt;
 &lt;td&gt;Grafana Cloud setup; web/ rebuild design spec; PR0 implementation plan; PR1 launcher plan&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;web/ bootstrap&lt;/td&gt;
 &lt;td&gt;package.json + Vite + tsconfig; Tailwind with Creative Warmth tokens; Vitest + jsdom + RTL; Zustand 5-slice store; T i18n component; cn() + Button + Dialog + Input primitives; SSE Zod schemas in shared/; React Router shell&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;A single 27-commit day did three things that would look unrelated in commit messages but were one decision: &lt;strong&gt;commit to the production target.&lt;/strong&gt; The runtime got the observability and shutdown semantics it needed to survive a deploy. The deploy stack itself was drafted as code instead of a wiki page. And the React rewrite started on day 0 with the same design tokens, type-checked SSE, and architectural seams the production version would need.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s instructive is what &lt;em&gt;didn&amp;rsquo;t&lt;/em&gt; happen on this day. No feature code. No new agents. No prompt tuning. The principle was: harden the floor first, build features on top of a hardened floor next. The next day&amp;rsquo;s 134-commit megapush would have been unsafe without this one — running PR1 → PR4 in a single day on top of a god-module queue, a &lt;code&gt;console.log&lt;/code&gt; codebase, and a worker that didn&amp;rsquo;t shut down cleanly would have buried genuine bugs under operational noise.&lt;/p&gt;
&lt;p&gt;Next: 134 commits in one day — PR1 Launcher, PR2 Workspace shell, PR3 SSE + chat flow, PR4 ApproveGate, and the database layer underneath all of it.&lt;/p&gt;</description></item><item><title>Creative Agent Studio #1 — The Mockup Era, and Why It Wore the Creative Warmth Theme</title><link>https://ice-ice-bear.github.io/posts/2026-04-07-creative-agent-studio-dev1/</link><pubDate>Tue, 07 Apr 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-04-07-creative-agent-studio-dev1/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post Creative Agent Studio #1 — The Mockup Era, and Why It Wore the Creative Warmth Theme" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;This is the first dev log for &lt;strong&gt;Creative Agent Studio&lt;/strong&gt; — a Korean-language multi-agent system for advertising creative work. A single user message enters a chat-first pipeline that walks four presentation stages — &lt;strong&gt;research → copy → scenario → storyboard&lt;/strong&gt; — gated by explicit human approval between stages.&lt;/p&gt;
&lt;p&gt;April was the mockup era. Twelve commits over two days produced a static HTML/JS prototype of the canvas, a runtime-flow spec, a &amp;ldquo;Creative Warmth&amp;rdquo; design theme that would survive the eventual React rewrite, and a deployment shape to put it on Vercel. The code thrown away later was not the point — the decisions written down in &lt;code&gt;interaction-model.md&lt;/code&gt; and the design tokens baked into the mockup were.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 Idea["Brief from user (chat)"] --&gt; Research["Research stage &amp;lt;br/&amp;gt; 리서치 에이전트"]
 Research --&gt; Gate1["GATE 1 &amp;lt;br/&amp;gt; key concept selection"]
 Gate1 --&gt; Copy["Copy stage &amp;lt;br/&amp;gt; 4 draft workers"]
 Copy --&gt; Gate2["GATE 2 &amp;lt;br/&amp;gt; copy approval"]
 Gate2 --&gt; Scenario["Scenario stage &amp;lt;br/&amp;gt; 3 specialists"]
 Scenario --&gt; Gate3["GATE 3-4 &amp;lt;br/&amp;gt; scenario approval"]
 Gate3 --&gt; Storyboard["Storyboard stage &amp;lt;br/&amp;gt; image generation"]
 Storyboard --&gt; Gate5["GATE 5 &amp;lt;br/&amp;gt; final approval"]&lt;/pre&gt;&lt;p&gt;Twelve commits, one running theme — &lt;strong&gt;lock in the principles before any of the implementation rusts.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="the-three-decisions-that-would-outlast-the-mockup"&gt;The Three Decisions That Would Outlast the Mockup
&lt;/h2&gt;&lt;p&gt;The most important file added in April wasn&amp;rsquo;t code — it was &lt;code&gt;interaction-model.md&lt;/code&gt;, which crystallized three product decisions into a single load-bearing document:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;결정 1. 인터랙션 모델 — 채팅 우선 (Chat-first).&lt;/strong&gt; The user&amp;rsquo;s primary input is free-form chat. Clicking buttons to advance stages is forbidden. The orchestrator interprets what they typed and dispatches agents accordingly.&lt;/p&gt;

 &lt;/blockquote&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;결정 2. 에이전트 투명성 — 한 줄 상태 표시 (One-line status).&lt;/strong&gt; When an agent is running, the feed shows a single one-line status message. No full dashboards, no real-time logs.&lt;/p&gt;

 &lt;/blockquote&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;결정 3. 게이트 기반 자동 실행 (Gate-based auto-run).&lt;/strong&gt; After each stage completes, the pipeline pauses and waits for explicit human approval before running the next stage automatically.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Every UI commit that followed — through April and across the May rewrite — would be checked against these three rules. The first commit of the mockup (&lt;code&gt;b67eb98 Add Diffs creative agent studio mockup&lt;/code&gt;) already had them encoded as DOM structure: no &amp;ldquo;next stage&amp;rdquo; button, agents appeared as bullet lists rather than a dashboard, and the composer was the only entry point.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="creative-warmth--the-design-theme"&gt;&amp;ldquo;Creative Warmth&amp;rdquo; — The Design Theme
&lt;/h2&gt;&lt;p&gt;The third commit (&lt;code&gt;72e0fdc&lt;/code&gt;) was the visual baseline that would survive everything: &lt;strong&gt;&amp;ldquo;Redesign: Apply Creative Warmth theme (warm white, DM Serif Display, Caveat).&amp;rdquo;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Three tokens, three reasons:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Token&lt;/th&gt;
 &lt;th&gt;Value&lt;/th&gt;
 &lt;th&gt;Why&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Background&lt;/td&gt;
 &lt;td&gt;Warm white (#FAF7F2)&lt;/td&gt;
 &lt;td&gt;Pure white reads as software. Warm tints read as workshop.&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Body type&lt;/td&gt;
 &lt;td&gt;DM Serif Display&lt;/td&gt;
 &lt;td&gt;A creative tool should feel literary, not productivity-coded.&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Handwritten labels&lt;/td&gt;
 &lt;td&gt;Caveat&lt;/td&gt;
 &lt;td&gt;Specialist agent names get a handwritten badge — a small humanizing touch on what is otherwise a dense work surface.&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The rule was negative: &lt;strong&gt;no pure black, no pure white, anywhere.&lt;/strong&gt; A subsequent commit (&lt;code&gt;640c755&lt;/code&gt;) had to fix the logo visibility because the original SVG was dark-on-dark — the fix was a &lt;code&gt;filter: brightness(0)&lt;/code&gt; CSS hack to invert it for the warm background rather than re-exporting the asset. Pragmatic, but the underlying constraint stayed: the design system would not bend to accommodate an asset that fought it.&lt;/p&gt;
&lt;p&gt;These tokens would migrate verbatim into &lt;code&gt;web/tailwind.config.ts&lt;/code&gt; six weeks later as the first feat(web) commit on the React side — proof that the design decisions were the right load-bearing layer.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="three-ui-cleanups-that-made-the-mockup-honest"&gt;Three UI Cleanups That Made the Mockup Honest
&lt;/h2&gt;&lt;p&gt;Three commits in succession (&lt;code&gt;3c225c9&lt;/code&gt;, &lt;code&gt;1be5253&lt;/code&gt;, &lt;code&gt;d37b4c9&lt;/code&gt;) attacked things that violated the three decisions:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-diff" data-lang="diff"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gd"&gt;- &amp;lt;p class=&amp;#34;eyebrow&amp;#34;&amp;gt;Project Management&amp;lt;/p&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gd"&gt;- &amp;lt;ul class=&amp;#34;agents&amp;#34;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gd"&gt;- &amp;lt;li&amp;gt;&amp;lt;span class=&amp;#34;dot dot-1&amp;#34;&amp;gt;&amp;lt;/span&amp;gt;Agent 1&amp;lt;/li&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gd"&gt;- &amp;lt;li&amp;gt;&amp;lt;span class=&amp;#34;dot dot-2&amp;#34;&amp;gt;&amp;lt;/span&amp;gt;Agent 2&amp;lt;/li&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gd"&gt;- &amp;lt;/ul&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gi"&gt;+ &amp;lt;ul class=&amp;#34;agents&amp;#34;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gi"&gt;+ &amp;lt;li&amp;gt;리서치 에이전트&amp;lt;/li&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gi"&gt;+ &amp;lt;li&amp;gt;Copy Draft Workers&amp;lt;/li&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gi"&gt;+ &amp;lt;/ul&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;3c225c9&lt;/code&gt; — Removed the &amp;ldquo;Project Management&amp;rdquo; eyebrow that framed the whole thing as a PM tool. Showed agent names as bullets instead of color-coded chips because the chips implied a dashboard.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;1be5253&lt;/code&gt; — Removed the &amp;ldquo;Background Agents&amp;rdquo; section from the status rail entirely. It violated decision 2 (one-line status, no full dashboard).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;d37b4c9&lt;/code&gt; — &lt;strong&gt;Composer overhaul.&lt;/strong&gt; Moved the model selector into the input bar itself, added a file-attach icon, updated the model list. Why this matters: the composer is decision 1&amp;rsquo;s entire surface area. If the composer doesn&amp;rsquo;t feel like the right place to type &amp;ldquo;더 도전적으로&amp;rdquo; or &amp;ldquo;이 두 개 합쳐줘&amp;rdquo;, the chat-first principle fails by accident.&lt;/p&gt;
&lt;p&gt;These weren&amp;rsquo;t features — they were &lt;em&gt;deletions&lt;/em&gt; enforcing the spec.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="runtime-flow-refinement--the-bones-underneath-the-mockup"&gt;Runtime Flow Refinement — The Bones Underneath the Mockup
&lt;/h2&gt;&lt;p&gt;Three docs and one feat commit on 2026-04-07 laid the runtime groundwork:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ecac2e5 docs: add production poc state and routing spec&lt;/code&gt; — defined what state the runtime would carry across the four stages.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;7e26c12 docs: add runtime state cleanup spec&lt;/code&gt; — defined how state would be retired between runs (relevant later when the React app needed to isolate per-session state).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;97df876 feat: refine workspace runtime flow&lt;/code&gt; — refined the actual flow code.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;21f0ebc chore: add deployment and design reference files&lt;/code&gt; — committed the deployment shape and a design-reference folder.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &amp;ldquo;deployment reference&amp;rdquo; was a Vercel buildCommand pointing at the mockup directory. That deployment target would survive the rewrite — when the React frontend shipped in May, the only change to &lt;code&gt;vercel.json&lt;/code&gt; was &lt;code&gt;outputDirectory: &amp;quot;web/dist&amp;quot;&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="commit-log"&gt;Commit Log
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Date&lt;/th&gt;
 &lt;th&gt;Message&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-06&lt;/td&gt;
 &lt;td&gt;Add Diffs creative agent studio mockup&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-06&lt;/td&gt;
 &lt;td&gt;Fix logo path for Vercel deployment&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-06&lt;/td&gt;
 &lt;td&gt;Redesign: Apply Creative Warmth theme (warm white, DM Serif Display, Caveat)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-06&lt;/td&gt;
 &lt;td&gt;Fix logo visibility: apply brightness(0) filter for dark logo on light bg&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-06&lt;/td&gt;
 &lt;td&gt;UI: Remove &amp;lsquo;Project Management&amp;rsquo; eyebrow, show agent names as bullets, add LLM dropdown&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-06&lt;/td&gt;
 &lt;td&gt;Remove Background Agents section from status rail&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-06&lt;/td&gt;
 &lt;td&gt;Composer: move model selector into input bar, add file attach icon, update models&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-07&lt;/td&gt;
 &lt;td&gt;docs: add production poc state and routing spec&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-07&lt;/td&gt;
 &lt;td&gt;docs: add runtime state cleanup spec&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-07&lt;/td&gt;
 &lt;td&gt;feat: refine workspace runtime flow&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-07&lt;/td&gt;
 &lt;td&gt;chore: ignore local workspace artifacts&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;2026-04-07&lt;/td&gt;
 &lt;td&gt;chore: add deployment and design reference files&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;April produced one piece of software (a static mockup) and three pieces of writing (&lt;code&gt;interaction-model.md&lt;/code&gt;, the routing spec, the runtime cleanup spec). The mockup would be deleted six weeks later — commit &lt;code&gt;chore: remove legacy mockup/ vanilla-JS SPA&lt;/code&gt; lands in the megapush — but every paragraph of the writing would still be load-bearing.&lt;/p&gt;
&lt;p&gt;The lesson worth keeping: &lt;strong&gt;product principles outlive implementation.&lt;/strong&gt; Writing down &amp;ldquo;chat-first, one-line status, gate-based auto-run&amp;rdquo; as decisions — not as features, not as UI requirements, but as decisions with a &lt;code&gt;결정&lt;/code&gt; heading and a &lt;code&gt;이유&lt;/code&gt; heading — meant that every subsequent UI commit had a tribunal to pass through. When the React rewrite started in May, those three decisions were copy-pasted directly into the new &lt;code&gt;web/&lt;/code&gt; tree&amp;rsquo;s principles. The mockup was throwaway; the principles weren&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Next: bitbucket migration, production-readying the runtime, and starting the React+Vite+TypeScript rewrite that would replace the entire mockup in one weekend.&lt;/p&gt;</description></item></channel></rss>