<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>GenAI Learning Moments</title>
<link>https://chendaniely.github.io/genai-learning-moments/</link>
<atom:link href="https://chendaniely.github.io/genai-learning-moments/index.xml" rel="self" type="application/rss+xml"/>
<description>Worked examples of human expertise correcting or improving an AI assistant&#39;s first answer.</description>
<generator>quarto-1.10.19</generator>
<lastBuildDate>Thu, 08 Oct 2026 00:00:00 GMT</lastBuildDate>
<item>
  <title>Learning Moment: Guard, Don’t Warn</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-10-08-guard-dont-warn.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>The project was a self-hosted web app (<code>myapp</code>) that had been built privately on a local branch. It was about to be published as a public GitHub repository. Before publishing, Claude ran a privacy audit of every commit. The audit found home-network hostnames, hardware details, and employer, conference and course names in the spec, the mockups and some commit messages. (The project’s name and file paths in this note, including in quotes, are placeholders.)</p>
<p>To remove them, Claude rewrote the git history with <code>git filter-repo</code>. Rewriting history is hard to undo, so first Claude saved the original, unscrubbed history as a git bundle (one file holding the whole repository). The bundle went <em>outside</em> the repo:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb1-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> bundle create ~/backups/myapp-pre-public.bundle <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--all</span></span></code></pre></div></div>
<p>The rewritten history passed every check, was pushed, and the repo was made public.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<p>Dictated; speech-to-text errors such as “get” for “git” are corrected:</p>
<blockquote class="blockquote">
<p>“let’s make sure that there’s nothing private in my git commit history and the actual codebase that will make it up into the repository and then let’s make the remote repository public”</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude did the audit, the history rewrite, the push, and the switch to public. Its summary listed the follow-ups. One of them was the backup:</p>
<blockquote class="blockquote">
<p><strong>Backup:</strong> your full original history, with the private details, is in <code>~/backups/myapp-pre-public.bundle</code>. Keep it local and never push it. Delete it once you’re sure you won’t need the old history.</p>
</blockquote>
<p>Claude had just spent the whole task hunting for files that must never be published. The audit had even found gaps in <code>.gitignore</code>, and Claude had fixed them. Yet this new file, the one that holds all the private history, got a sentence in chat. It got no rule.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User</strong> (dictated):</p>
<blockquote class="blockquote">
<p>“can you make sure those files that should never be pushed [have a] gitignore file associated with it”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Claude checked where each file actually sat and what could catch it:</p>
<ol type="1">
<li><p><strong>The bundle’s folder:</strong> <code>~/backups</code> is not itself a git repo, so the bundle couldn’t be pushed from where it was. But nothing would stop it being committed if it were ever copied or moved into a repo.</p></li>
<li><p><strong>The repo’s local-only files:</strong> Claude listed every ignored and untracked file in the repo, then tested each “never push” pattern against made-up paths:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb2-1"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> f <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> config.toml packages/x/config.toml docker/deploy.env .env docker/.env <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">\</span></span>
<span id="cb2-2">         secrets/token.txt .claude/settings.json myapp-pre-public.bundle<span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">;</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">do</span></span>
<span id="cb2-3">  <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> check-ignore <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-q</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--no-index</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">&amp;&amp;</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"ignored     </span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">||</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"NOT IGNORED </span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span></span>
<span id="cb2-4"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">done</span></span></code></pre></div></div>
<p>Every pattern was ignored except one: <code>NOT IGNORED myapp-pre-public.bundle</code>.</p></li>
<li><p><strong>Two rules, two scopes:</strong></p>
<ul>
<li><code>*.bundle</code> in the repo’s <code>.gitignore</code>, for a bundle that ever lands inside this repo.</li>
<li><code>*.bundle</code> in the user’s global git ignore file (<code>~/.config/git/ignore</code>). That is the ignore file that applies to a file living outside any repo, and it covers every repo on the machine.</li>
</ul></li>
<li><p><strong>The proof:</strong> a throwaway repo showed the global rule catching a bundle it had never seen:</p>
<pre><code>~/.config/git/ignore:3:*.bundle  x.bundle</code></pre></li>
</ol>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>The never-push rule now lives where git enforces it, not in the chat log:</p>
<pre class="gitignore"><code># myapp/.gitignore
# Git bundles (such as the pre-public history backup) can carry private history.
*.bundle</code></pre>
<pre class="gitignore"><code># ~/.config/git/ignore
# Git bundles are full-history backups and may hold private data: never commit one.
*.bundle</code></pre>
<p>The repo already had rules for its other local-only files: <code>config.toml</code>, <code>.env*</code>, <code>secrets/</code>, the deploy values and the agent workspace. All of them were tested with <code>git check-ignore</code>, not assumed.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<ul>
<li><strong>A backup before rewriting.</strong> Making a full backup before an irreversible rewrite is good practice. Keeping it outside the repo was the right call too.</li>
<li><strong>An honest warning.</strong> The warning was accurate: it named the file, said it held private data, and said never to push it.</li>
<li><strong>Thorough elsewhere.</strong> For files that already existed, Claude was thorough: the audit found and fixed <code>.gitignore</code> gaps before anything was published.</li>
</ul>
<p><strong>What required human expertise:</strong></p>
<p>The user saw that a warning is a control that depends on a person remembering it, perhaps months later, perhaps while tired. A rule doesn’t depend on anyone. Asking for the <code>.gitignore</code> turned “please be careful” into “git will refuse to be careless”. It is the same instinct as putting a guard on a saw rather than a sign saying “mind your fingers”.</p>
<p><strong>Why Claude missed it:</strong></p>
<ul>
<li><strong>It was created after the audit.</strong> The bundle didn’t exist when the audit ran. Claude made it partway through the cleanup, as a means to an end, and never put the new file through the same “can this be published?” test it had just used on everything else.</li>
<li><strong>Telling felt like finishing.</strong> Writing “never push it” felt like completing the safety step. Claude stated the constraint instead of enforcing it, even though it had the tools to enforce it.</li>
<li><strong>No repo rule seemed to apply.</strong> The bundle sat outside the repo, so a repo <code>.gitignore</code> looked irrelevant. The user’s global ignore file is the place that covers a file with no repo of its own.</li>
</ul>
<p><strong>Key takeaway:</strong></p>
<p>When an AI tells you “never commit this file”, ask it to make committing the file impossible. A tested rule in <code>.gitignore</code> beats a warning in chat.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>git</category>
  <category>security</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-10-08-guard-dont-warn.html</guid>
  <pubDate>Thu, 08 Oct 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: A Summary That Quoted the Post and Still Got It Wrong</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-10-07-summaries-lose-the-argument.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>An instructor had just finished the second post in a blog series about using AI in their teaching. The post covers 2 courses: a computing course in a data science master’s program (called “the master’s course” below), and a health data science course with no coding prerequisites (“the health course”). Its argument is that there are 2 things students should get out of AI in class: using AI to learn something, and doing stuff to learn the AI.</p>
<p>For the first post, the instructor had kept a local planning file of social media drafts. For this one they wanted the same thing.</p>
<p>Course codes, names and URLs are generalized below; the structure of the exchange is unchanged.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“ok just like my previous post. let’s create a separate file for socials: linkedin, bluesky, and mastodon. dont’e forget the character counts for each service and some relevant hashtags to get more enguagement”</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude read the first post’s social file and copied its layout: LinkedIn as a short “teaser” plus a longer “Summary field,” then a Bluesky post and a Mastodon post.</p>
<p>The mechanics were careful. A script counted every draft exactly, using each platform’s rules: graphemes for Bluesky’s 300 limit, and 23 characters per link for Mastodon’s 500. The first Bluesky and Mastodon drafts came in over their limits, so Claude trimmed them, recounted, and trimmed again until they fit. Hashtags matched each platform’s habits, like Bluesky’s feed tags and CamelCase tags on Mastodon so screen readers can read them.</p>
<p>The content was assembled mostly from sentences lifted out of the post. Two of them caused trouble later. The LinkedIn summary covered the health course and then went straight into:</p>
<blockquote class="blockquote">
<p>“But you still need to know what your tools can do. One student had ChatGPT rewrite all of the CSS on their Quarto slides, when the fix was a one or two liner: Quarto’s built-in .smaller class.”</p>
</blockquote>
<p>And all 3 posts ended on the post’s own line about vibe coding, which Bluesky’s limit shortened even further:</p>
<blockquote class="blockquote">
<p>“Fine for me to vibe code without knowing CSS. Not for a student learning Quarto.”</p>
</blockquote>
<p>The link to the post sat at the end of the teaser. The fact that this was part 2 of a series got one secondary line.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“please just give me the copy/linked in sectionpaste for linked in i’m not sure why there are like 3 sections for the”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong> Merged the teaser and summary into one paste-ready LinkedIn post, replaced the 3 LinkedIn sections in the file with it, and explained that the layout had been copied from the first post’s file. In the merge, the link moved to near the bottom.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“in the linked in:”One student had ChatGPT rewrite all of the CSS on their Quarto slides” makes it seem like it’s still from [the health course]. when that happened in [the master’s course]”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong> Added the course name to the sentence: “In [the master’s course], one student had ChatGPT rewrite…”</p>
<p><strong>Turn 3 — User:</strong></p>
<blockquote class="blockquote">
<p>“the link to the blog needs to be higher up so it’s above the fold”</p>
</blockquote>
<p><strong>Turn 3 — Claude:</strong> Moved the link into the opening paragraph, right after the hook, so it starts at character 147, before LinkedIn cuts the post off with “…see more.”</p>
<p><strong>Turn 4 — User:</strong></p>
<blockquote class="blockquote">
<p>“i don’t like the”Fine for me to vibe code a website without knowing CSS. Not for a student whose homework is learning Quarto.” I wanted to read something along the lines of it’s fine if you want to do a site project and you learn how to use the tools along the way, but it’s not OK when you are vibecoding something that you are actively trying to learn without thinking about it”</p>
</blockquote>
<p><strong>Turn 4 — Claude:</strong> Rewrote the line in all 3 posts around that principle, for example: “It’s fine to vibe code a side project and learn the tools along the way. It’s not OK to vibe code the thing you’re actively trying to learn without thinking about it.”</p>
<p><strong>Turn 5 — User:</strong></p>
<blockquote class="blockquote">
<p>“For LinkedIn and possibly the other social media, I’d like to be able to say that this is the next post in my teaching with AI in the classroom series or incorporating iron [AI] in the classroom series and I think that would be a good follow up for people who saw the first post as well, so I want that Incorporated and rewrite the whole thing so it flows around that framing”</p>
</blockquote>
<p><strong>Turn 5 — Claude:</strong> Rewrote all 3 posts as a follow-up. Each one now opens with “Part 2 of my … series is up,” recaps part 1 in a sentence, and points people who missed it back to part 1. Turns 2 through 4 were kept, and every draft was recounted and trimmed to fit.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>The LinkedIn post now opens like this (URLs are placeholders):</p>
<blockquote class="blockquote">
<p>Part 2 of my Incorporating AI in the Classroom series is up: https://example.github.io/posts/part-2/</p>
<p>In part 1, an agent did the typing in my review sessions while the class read and judged its commands. This time I zoom out to the 2 things I want students to get out of AI in class: using AI to learn something, and just doing stuff to learn the AI.</p>
</blockquote>
<p>The link starts at character 62. The CSS story names its course, and the vibe coding line states the principle instead of the instructor-versus-student example. The post ends with “If you missed part 1, it’s here,” plus the link. Bluesky (294/300) and Mastodon (465/500, linking both parts) follow the same framing.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong> Everything that could be measured. The counts were exact and used each platform’s actual counting rules, every draft fit its limit, the hashtags matched each platform’s habits, and the drafts reused the instructor’s own wording instead of inventing new claims. That last part matters: none of the problems came from Claude making things up.</p>
<p><strong>What required human expertise:</strong> Knowing the content, the audience and the platform better than the text did.</p>
<ul>
<li><em>Who did what, where.</em> Only the author knew, without rereading, that the CSS story belonged to the other course.</li>
<li><em>What the argument actually was.</em> The vibe coding line <em>was</em> in the post, nearly word for word. In the post, it comes after the full argument and reads as one example of it. Alone in a social post, “fine for me, not for a student” sounds like a double standard. The author wanted the principle behind the example: side project versus the thing you’re trying to learn.</li>
<li><em>How the platform shows the post.</em> LinkedIn hides everything below the first few lines, so a link at the bottom is a link most people never see.</li>
<li><em>Who is reading.</em> This is part 2. The warmest readers are the people who saw part 1, so “this is the follow-up” is the hook, not a footnote.</li>
</ul>
<p><strong>Why Claude missed it:</strong></p>
<ol type="1">
<li><em>Summarizing by lifting sentences.</em> Claude built the drafts from sentences taken out of the post and reordered. The post introduced the CSS story with a transition that named the course (“The flip side… showed up in [the master’s course]”). As a summary, that transition looked like filler and got dropped. Once the story sat right after a paragraph about the other course, its position implied the wrong course. Every sentence was accurate, but the arrangement wasn’t.</li>
<li><em>Matching the text instead of the intent.</em> Lifting the author’s own sentence felt like the safest choice. But a sentence’s meaning depends on what’s around it. The surroundings changed and the words didn’t, so being faithful to the words made it unfaithful to the point.</li>
<li><em>The checks measured length, not meaning.</em> The counting script was the loop Claude used to decide it was done, and it only checks length. When a draft ran over, the cuts came out of the meaning (“2 things” became “what,” and “a site” disappeared), because nothing in the loop could notice. Passing that check made the drafts feel finished.</li>
<li><em>Each post written as if it stood alone.</em> Claude knew this was part 2, since the post itself says so in a callout at the top. But Claude treated “part 2” as a fact to mention, not as the main way to reach people. Similarly, Claude knew LinkedIn’s 3,000-character limit but didn’t think about where LinkedIn cuts a post off. It knew the rules but didn’t think about how readers would actually see the post.</li>
<li><em>Copying a layout without asking what it was for.</em> The teaser plus Summary layout was copied from the previous file without anyone checking whether this post needed it. That cost the first correction.</li>
</ol>
<p>It took 5 turns to fix, and the author caught each problem only by reading the drafts as a reader would. That’s the real cost of these misses: they pass every automated check and turn up only when a person reads the result.</p>
<p><strong>Key takeaway:</strong> When AI condenses your writing, check who did what and where, and whether each point still holds up on its own, because a summary can quote you exactly and still say something you didn’t mean.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>writing</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-10-07-summaries-lose-the-argument.html</guid>
  <pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: A Hand-Built Card Where a Quarto Callout Would Do</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-10-07-use-the-framework-first.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A personal website and blog built with Quarto, with a fairly heavy custom theme on top: a commented <code>theme.scss</code>, dark-mode overrides in <code>theme-dark.scss</code>, and a small script that gives cards a random resting tilt.</p>
<p>The task was a blog post about using AI in teaching. One sentence leaned on a term that needed explaining:</p>
<blockquote class="blockquote">
<p>vibe coding is Andrej Karpathy’s term for coding where you “forget that the code even exists”</p>
</blockquote>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“for (vibe coding is Andrej Karpathy’s term for coding where you”forget that the code even exists”) i feel like it would be fun to have some kind of definition card that definds this/ card / callout.”</p>
</blockquote>
<p>A follow-up message added a constraint: don’t link to X, because those links rot.</p>
<blockquote class="blockquote">
<p>“i don’t trus x links. we lost so many links to twitter so here’s a screenshot of the karpathy post […] don’t forget the full alt text when you insert it”</p>
</blockquote>
<p>Note the wording of the first message: “card / callout.” Both options were on the table.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude built a bespoke dictionary-entry card. The markup was a Pandoc definition list inside a custom <code>.definition</code> div, with a pronunciation hidden from screen readers and a part-of-speech label:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb1-1">::: {.definition}</span>
<span id="cb1-2">vibe coding <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">[</span><span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">/vaɪb ˈkoʊdɪŋ/</span><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">]</span>{.pron aria-hidden="true"} <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">[</span><span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">noun</span><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">]</span>{.pos}</span>
<span id="cb1-3">:   Andrej Karpathy's term for coding where you "fully give in to the vibes,</span>
<span id="cb1-4">    embrace exponentials, and forget that the code even exists."</span>
<span id="cb1-5"></span>
<span id="cb1-6"><span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">    ![Karpathy's post that named it, February 2, 2025.](karpathy.png){fig-alt="..."}</span></span>
<span id="cb1-7">:::</span></code></pre></div></div>
<p>Making it look right took changes in four places:</p>
<ul>
<li><strong><code>theme.scss</code>:</strong> about 40 new lines. The card’s background, border, radius and shadow, an orange rule under the term styled as an index card’s header line, muted styles for the pronunciation, part of speech and caption, and a rotation rule in the motion section.</li>
<li><strong><code>theme-dark.scss</code>:</strong> dark-mode background and text colors for the card.</li>
<li><strong><code>scroll-reveal.html</code>:</strong> one line of JavaScript, <code>tilt(".definition", 1)</code>, so the card got a random tilt like the site’s other cards.</li>
<li><strong><code>AGENTS.md</code>:</strong> a new section documenting the markup, so the pattern could be reused.</li>
</ul>
<p>Claude checked it in both light and dark mode with browser screenshots. It looked good, and the user said so.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“i like your definitions card, but i think it’s a bit too custom. let’s use the https://quarto.thecoatlessprofessor.com/custom-callout/ quarto extension for this instead of a custom div (easeir to implement and re-use in the future)”</p>
</blockquote>
<p>The user later said why, while asking for this write-up:</p>
<blockquote class="blockquote">
<p>“this is a quarto site and i’m trying to leverage more of the quarto features instead of writing massive custom things”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong> Read the extension’s docs, then confirmed that every uncommitted change in the three style files was its own before reverting them. Installed the extension with <code>quarto add coatless-quarto/custom-callout</code>, defined a <code>definition</code> callout type once in <code>_quarto.yml</code>, and swapped the post’s markup to <code>::: {.definition title="vibe coding"}</code>. Claude also read the extension’s Lua filter to see how it applies color, and rendered screenshots in both modes again.</p>
<p>In the switch, Claude also dropped the pronunciation and the part of speech. It filed them under “too custom” along with the SCSS.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“For the vibecoding definition, I liked what you did before were you actually had the phonetic pronunciation for the term by coding? I thought that was a nice little fun touch so we can have also definition: in the title as well so it looks more like a dictionary entry.”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong> Went back to the extension’s Lua source and found that a heading as the first element of the div becomes the callout’s title. A heading can hold formatted text, so the pronunciation and the italic <em>noun</em> fit in the title. Rendering it turned up one more problem: Quarto already gives every callout a hidden “Definition” label for screen readers, so a visible “Definition:” would be read twice. Claude hid the visible label from screen readers with <code>aria-hidden</code>, the same way the pronunciation was hidden.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>One <code>quarto add</code>, seven lines of YAML in <code>_quarto.yml</code>, and no custom CSS or JavaScript:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode yaml code-with-copy"><code class="sourceCode yaml"><span id="cb2-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">filters</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span></span>
<span id="cb2-2"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">  </span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">-</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> custom-callout</span></span>
<span id="cb2-3"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">custom-callout</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span></span>
<span id="cb2-4"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">  </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">definition</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span></span>
<span id="cb2-5"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">    </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">title</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> </span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Definition"</span></span>
<span id="cb2-6"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">    </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">icon-symbol</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> </span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"fa-book"</span></span>
<span id="cb2-7"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">    </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">color</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> </span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"#7E7468"</span><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">  # brand warm-gray: the built-in callouts already use blue, green, orange, burgundy</span></span></code></pre></div></div>
<p>The post uses it like any built-in callout:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb3-1">::: {.definition}</span>
<span id="cb3-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">## [Definition:]{aria-hidden="true"} vibe coding [/vaɪb ˈkoʊdɪŋ/]{aria-hidden="true"} *noun*</span></span>
<span id="cb3-3"></span>
<span id="cb3-4">Andrej Karpathy's term for coding where you "fully give in to the vibes,</span>
<span id="cb3-5">embrace exponentials, and forget that the code even exists."</span>
<span id="cb3-6"></span>
<span id="cb3-7"><span class="al" style="color: #AD0000;
background-color: null;
font-style: inherit;">![Karpathy's post that named it, February 2, 2025.](karpathy.png)</span>{fig-alt="..."}</span>
<span id="cb3-8">:::</span></code></pre></div></div>
<p>Dark mode, the icon and the tinted header come from the callout system. Any future page can define a term the same way, and the <code>AGENTS.md</code> section now points to the extension instead of to custom styles.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong> The content and the reader-facing details. The user liked the card, and most of it survived: the dictionary framing, the pronunciation, the screenshot instead of an X link that could rot, full alt text for that screenshot, and checking both color modes before calling it done. When it came time to undo the custom styles, Claude checked that the uncommitted changes were all its own before reverting them, rather than assuming.</p>
<p><strong>What required human expertise:</strong> Knowing the tools. The user knew Quarto well enough to know that a callout was the right building block, and that an extension already existed to add new callout types. As they put it later:</p>
<blockquote class="blockquote">
<p>“this way I’m using more [Quarto] features instead of just making something that’s [coded] from scratch and that’s a signal around me knowing the tools that I’m working with”</p>
</blockquote>
<p>That’s the expertise here. An AI can write custom code to almost any size. The person who knows the tool knows when none of it is needed.</p>
<p>The irony is that the blog post being edited makes this exact point. In one of the instructor’s courses, a student had ChatGPT rewrite all of the CSS on their Quarto slides to make the text fit, when the fix was Quarto’s built-in <code>.smaller</code> class, a one- or two-line change. The post’s lesson is that “you still need to know what your other tools (like Quarto) are already capable of, or the LLM is going to go off and do something you don’t want.” In the same post, Claude did what the student’s ChatGPT did: 40 lines of custom styles for something Quarto already had a feature for. And the person who knew the tool caught it.</p>
<p>There’s also a maintenance judgment. The user wants this to be a Quarto site that leans on Quarto, not a hand-styled site that happens to be built with Quarto. A callout type is seven lines of config that any page can use, while the custom card was four files of styles and script that future posts would have to remember and match.</p>
<p>The second correction mattered just as much. “Too custom” was about the <em>implementation</em>, not the <em>idea</em>. Claude stripped the fun parts along with the SCSS. The user wanted the same dictionary entry, built from framework parts.</p>
<p><strong>Why Claude missed it:</strong></p>
<ol type="1">
<li><em>Local patterns beat framework patterns.</em> The repo already had a sizable custom theme: signpost cards, a tilt script, a dark-mode override file. Claude extended what was in front of it, so “how this repo does things” looked like “write more SCSS.” The repo’s history pulled harder than the framework’s features.</li>
<li><em>The request’s wording steered toward visual design.</em> “Fun” and “card” read as a design brief, even though the same sentence said “callout.” Claude picked the more open-ended option instead of asking which one the user meant.</li>
<li><em>Reuse cost wasn’t part of the decision.</em> Claude did document the markup for reuse, but documenting a custom pattern isn’t the same as making it cheap. The question “what does the next post have to do to use this?” was answered by adding docs, not by choosing a simpler mechanism.</li>
<li><em>Extensions take looking up.</em> Claude knows Quarto’s built-in callouts well, but third-party extensions take a search, and Claude didn’t search before building.</li>
<li><em>Over-correcting on the switch.</em> Told the card was too custom, Claude cut the content’s personality along with the implementation. It’s the same over-correction as in an earlier moment, <a href="../posts/2026-07-28-over-engineering-the-ask.html">Thirty Lines of Bash Where <code>cat</code> Would Do</a>: swinging from over-built to stripped-bare instead of asking which parts were actually the problem. Reading the extension’s source showed that it supported the fun parts all along.</li>
</ol>
<p><strong>Key takeaway:</strong> Knowing your tools is what lets you catch an AI building from scratch something the tool already does. So before accepting custom code, ask “does the framework, or one of its extensions, already do this?” And when you switch to the framework’s version, bring the content along: what was too custom was the implementation, not the idea.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>quarto</category>
  <category>css</category>
  <category>over-engineering</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-10-07-use-the-framework-first.html</guid>
  <pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: A Correct Repro That Nobody Can Teach</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-09-13-teachable-beats-correct.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>Writing the Week 3 group lab for a graduate computing course. Students are put into groups of three or four, share one Quarto reveal.js slide deck, and all edit the same file at the same time. The entire point of the lab is that they trigger real Git merge conflicts and resolve them.</p>
<p>Each student has to submit a screenshot showing <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt;</code>, <code>=======</code>, and <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt;</code> in the <code>.qmd</code> file, as proof that they personally hit a conflict and looked at it.</p>
<p>That requirement creates an obvious edge case: a student who misses lab, or whose group finishes without them, still needs a conflict of their own. So the assignment needs a documented way to produce one solo.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“if you are not in lab with your group at the time of the assignment, you can create a merge conflict with yourself.”</p>
</blockquote>
<p>One line in a much longer spec. The instructor said <em>what</em> the fallback was and left the <em>how</em> to Claude.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude wrote a two-clone recipe:</p>
<blockquote class="blockquote">
<p>Clone the <strong>same</strong> repository a second time into a different folder and treat that clone as another person:</p>
<ol type="1">
<li>In your <strong>first</strong> clone, change a line in <code>index.qmd</code>, commit, and push.</li>
<li>In the <strong>second</strong> clone, change the <strong>same line</strong> to something different, and commit.</li>
<li>Still in the second clone, pull.</li>
</ol>
<p>You now have a genuine conflict to screenshot and resolve. Delete the second clone afterwards so it does not confuse you later, and make sure the version you leave on GitHub is the one you meant to keep.</p>
</blockquote>
<p>This works. Two working copies of one remote are two independent lines of history, so making them disagree on a line and pulling produces a textbook conflict.</p>
<p>It survived several rounds of review in that form, including a pass that deliberately cut hand-holding from the rest of the document.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“2 clones of the same repo is not how it was demo’ed in class. in class it was done by a github web edit and a local edit at the same time”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Replaced the recipe in both places it appeared, and checked the lecture notes for the wording used in class:</p>
<blockquote class="blockquote">
<ol type="1">
<li>Edit <code>index.qmd</code> on GitHub in your browser and commit the change there.</li>
<li>On your laptop, <strong>without pulling first</strong>, change the same line to something different and commit.</li>
<li>Pull.</li>
</ol>
</blockquote>
<p>Claude also flagged that <code>**without pulling first**</code> is load-bearing: it is the one step a student can skip while still appearing to follow the instructions, and skipping it produces a clean merge and no conflict.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“you’re not wrong on how to create the conflict, but that’s very complex to do, unlikely to happen in real life, and also difficult to teach”</p>
</blockquote>
<p>Three separate objections, none of which is “incorrect”.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>The web-edit recipe, in the assignment and in the cross-reference that points to it.</p>
<p>Beyond being what students had already seen demonstrated, it is shorter: no second clone to create, keep straight, or delete afterwards. The cleanup sentence that the two-clone version needed disappeared with it.</p>
<p>It also models something real. Editing a file through the GitHub web interface and forgetting you did it is an ordinary way to end up with a conflict. Maintaining two clones of the same repository on one laptop is not.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>The mechanism. Both recipes produce a genuine conflict for the same underlying reason, and Claude correctly identified the non-obvious failure mode in the new one (pull first and there is nothing to conflict with). Asked in isolation “how do I create a merge conflict with myself”, the two-clone answer is defensible.</p>
<p><strong>What required human expertise:</strong></p>
<p>Knowing that this was not a question about Git. It was a question about <em>course design</em>. The answer had to satisfy constraints the prompt never stated:</p>
<ul>
<li>It has to match what students were shown in lecture, or the assignment contradicts the teaching.</li>
<li>It has to be simple enough to walk a struggling student through in a lab session.</li>
<li>It should resemble a situation they will actually meet again.</li>
</ul>
<p>The instructor was the only one who knew the first constraint, and the only one positioned to weigh the other two.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Claude optimized for the stated goal, which was producing a conflict, and treated correctness as the finish line. It had no access to what happened in the lecture, and did not think to ask, even while writing a document whose other sections carefully cross-reference the textbook.</p>
<p>The deeper failure is that pedagogical documents have a success criterion that is not correctness. A recipe in an assignment is not just executed, it is taught, debugged over a student’s shoulder, and remembered. Claude was not weighing those at all. Notably, this survived a review pass explicitly aimed at cutting complexity: the pass trimmed <em>prose</em> while leaving an over-complicated <em>procedure</em> untouched, because complexity in a procedure does not look like verbosity.</p>
<p><strong>Key takeaway:</strong></p>
<p>When you ask an AI for instructions someone else will follow, say who will follow them and what they have already been taught. Otherwise you get the answer that is most correct rather than the one that is most teachable, and those are rarely the same.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>teaching</category>
  <category>git</category>
  <category>writing</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-09-13-teachable-beats-correct.html</guid>
  <pubDate>Sun, 13 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: A New Function One Underscore From the Old One</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-09-03-fix-belongs-in-the-wrapper.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A course-management script that drives GitHub Enterprise as an LMS — it creates per-student assignment repos, grants access, opens notification issues, and collects grades. For years it depended on a <em>fork</em> of <code>github3.py</code>, pinned to a 2016 alpha (<code>1.0.0a4</code>), because the fork carried a handful of patches the upstream library lacked. That fork is now unmaintained, and the pinned alpha does not even import on modern Python: it does <code>from collections import Callable</code>, removed in 3.10.</p>
<p>The task was to move to upstream <code>github3.py</code> 4.0.1.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“i’m trying to get the code working with the upgraded github3 package. it was previously using a forked repo that is no longer maintianed.”</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>The diagnosis went well. Claude cloned the fork, diffed it against its 2016 upstream base, and found four additions — two of which are now upstream, and two of which the project never called. Nothing in the fork was load-bearing.</p>
<p>Three real regressions came from the version jump itself. The interesting one: <code>repo.add_collaborator()</code> in 4.x counts only HTTP 201 as success, but GitHub answers <strong>204</strong> when the user is already a collaborator. The fork had checked for 204. So a re-run reported every student who already had access as a failure — 151 of 167 repos printing <code>FAILED to add ...</code> while the underlying API call was in fact succeeding.</p>
<p>Claude confirmed this against the live server rather than assuming it: a known existing collaborator returned 204, a student who was not an org member returned a genuine 404.</p>
<p>The fix Claude wrote was a new module-level function:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">def</span> add_collaborator(repo, username, permission<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"push"</span>):</span>
<span id="cb1-2">    <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">"""Add `username` to `repo` as a collaborator, returning True on success."""</span></span>
<span id="cb1-3">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">not</span> username:</span>
<span id="cb1-4">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span></span>
<span id="cb1-5">    url <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> repo._build_url(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"collaborators"</span>, <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">str</span>(username), base_url<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>repo._api)</span>
<span id="cb1-6">    response <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> repo._put(url, data<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>json.dumps({<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"permission"</span>: permission}))</span>
<span id="cb1-7">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> response.status_code <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> (<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">201</span>, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">204</span>):</span>
<span id="cb1-8">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span></span>
<span id="cb1-9">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> response.status_code <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">400</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">and</span> response.status_code <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">404</span>:</span>
<span id="cb1-10">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">raise</span> github3.exceptions.error_for(response)</span>
<span id="cb1-11">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span></span></code></pre></div></div>
<p>and then rewired the call sites to use it, including the one inside the project’s own existing method:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb2-1">    <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">def</span> _add_collaborator(<span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>, repo, user, permission<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"push"</span>):</span>
<span id="cb2-2">        ...</span>
<span id="cb2-3">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">try</span>:</span>
<span id="cb2-4">            output <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> add_collaborator(repo, user, permission<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>permission)</span>
<span id="cb2-5">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">except</span> GitHubError <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> error:</span>
<span id="cb2-6">            ...</span></code></pre></div></div>
<p>Claude verified it end to end against 167 real repos — exit 0, no tracebacks, 151 spurious failures gone — and reported success.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“why did you create a new add_collaborator function when i already have an _add_collaborator method? try again”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong> Conceded immediately, because the objection is plainly right. The file now contained <code>add_collaborator</code> and <code>_add_collaborator</code>, names one underscore apart, splitting a single job in half. <code>_add_collaborator</code> had <em>always</em> been the project’s wrapper for “add a collaborator and report what happened” — it warns when the user is not an org member, checks the repo is known, catches <code>GitHubError</code>, and prints the success or failure line. The status-code handling belonged inside it.</p>
<p>Claude checked one thing before restructuring: whether <code>github3</code>’s <code>ConnectionError</code> is a subclass of <code>GitHubError</code>. It is not — it descends from <code>TransportError</code> — which meant the existing <code>except GitHubError</code> was never catching timeouts and the refactor would not silently change error handling.</p>
<p>The helper was deleted and its logic moved inline. The <code>pushfeedback</code> call sites went back to the library’s own <code>repo.add_collaborator()</code>.</p>
<p>A re-run gave identical counts to the previous run, confirming a pure refactor.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“a few things: can you actuall revert your change you just made. and have it create 2 functions like before? then commit that, THEN commit the changes for incorporating into _add_collaborator. i would like to use those 2 diffs as a teaching example”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong> Reverted to the two-function version, committed it, re-applied the refactor, and committed that — producing two clean diffs where there would otherwise have been one commit and a discarded mistake.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>The status handling lives in the method that was already there:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb3-1">    <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">def</span> _add_collaborator(<span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>, repo, user, permission<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"push"</span>):</span>
<span id="cb3-2">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">not</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>.org.is_member(user):</span>
<span id="cb3-3">            <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Warning: </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%s</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> is not a member of the </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%s</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> organization. ..."</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%</span> (user, <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>.org.login))</span>
<span id="cb3-4"></span>
<span id="cb3-5">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> repo.name <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">not</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>.repos:</span>
<span id="cb3-6">            <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Could not find Repository </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%s</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">."</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%</span> repo.name)</span>
<span id="cb3-7">            <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span></span>
<span id="cb3-8"></span>
<span id="cb3-9">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">try</span>:</span>
<span id="cb3-10">            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># repo.add_collaborator() only counts HTTP 201 (an invitation was</span></span>
<span id="cb3-11">            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># created) as success, but GitHub answers 204 when the user is</span></span>
<span id="cb3-12">            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># already a collaborator or is an org member who gets access</span></span>
<span id="cb3-13">            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># directly -- which reported every student who already had access</span></span>
<span id="cb3-14">            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># as a failure. The github3.py fork we used to depend on checked</span></span>
<span id="cb3-15">            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># for 204 instead, so accept both. A 404 is still a real failure,</span></span>
<span id="cb3-16">            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># e.g. there is no such user on this server.</span></span>
<span id="cb3-17">            url <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> repo._build_url(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"collaborators"</span>, <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">str</span>(user), base_url<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>repo._api)</span>
<span id="cb3-18">            response <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> repo._put(url, data<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>json.dumps({<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"permission"</span>: permission}))</span>
<span id="cb3-19">            <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> response.status_code <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">400</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">and</span> response.status_code <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">404</span>:</span>
<span id="cb3-20">                <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">raise</span> github3.exceptions.error_for(response)</span>
<span id="cb3-21">            output <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> response.status_code <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> (<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">201</span>, <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">204</span>)</span>
<span id="cb3-22">        <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">except</span> GitHubError <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> error:</span>
<span id="cb3-23">            ...</span></code></pre></div></div>
<p>Net effect of the second commit: <strong>+15 / −24</strong>. Fewer lines, one function instead of two, and no behaviour change — the same run produced an identical 153 added / 14 failed / 167 issues skipped.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong> The hard part, which was the diagnosis. The 201-vs-204 mismatch is not guessable; it was found by diffing an abandoned fork against its upstream base and then confirmed with live calls that showed a 204 for an existing collaborator and a genuine 404 for a non-member. The fix <em>logic</em> — accept both codes, still fail on 404, still raise on other 4xx/5xx — survived the refactor completely unchanged. Only its address changed. Claude also verified against the real system instead of asserting success, and distinguished the 151 false alarms from 16 genuine failures rather than reporting “all fixed.”</p>
<p><strong>What required human expertise:</strong> Knowing the shape of your own codebase. <code>_add_collaborator</code> was already the wrapper for exactly this operation, so there was a correct home for the change and Claude built a second one next door. This is cheap knowledge for the person who wrote the file and expensive for anyone else — which is precisely the kind of thing a human reviewer should expect to supply.</p>
<p><strong>Why Claude missed it:</strong></p>
<ol type="1">
<li><p><em>Mirroring the library instead of the codebase.</em> The fix was a replacement for <code>repo.add_collaborator(...)</code>, so Claude wrote a drop-in with the same name and nearly the same signature. That is the right instinct when you are patching a library and the wrong frame when the project already owns a wrapper around that library call. The new function was shaped by its ancestor rather than by its use.</p></li>
<li><p><em>An edge case drove the main design.</em> Of three call sites, one — operating on a <em>fork</em> of a repo — genuinely could not use <code>_add_collaborator</code>, because that method looks the repo up in <code>self.repos</code> and a fork is not there. That real constraint made a standalone function feel necessary. But that call site <strong>discards the return value</strong>, so the 201-vs-204 distinction, the whole point of the fix, never mattered there. Claude let the one site that did not need the fix determine the shape of the fix.</p></li>
<li><p><em>Reading code to find a call site is not reading it to find a home.</em> <code>_add_collaborator</code> was on screen; Claude had opened it specifically to find the line to rewire. It got filed as “a caller to update” rather than “the place this logic goes.” Same text, different question, and Claude only asked the narrow one.</p></li>
<li><p><em>The name collision never registered.</em> <code>add_collaborator</code> beside <code>_add_collaborator</code> is a code smell visible at a glance, but Claude picked the name by matching the library method it was replacing and never checked it against the names already in the file.</p></li>
</ol>
<p>There is a tell that the split was wrong, sitting in the helper itself: the <code>if not username: return False</code> guard was dead code. It was inherited from the library’s implementation, but the only caller, <code>_add_collaborator</code>, cannot pass an empty user. A function carrying a guard its sole caller makes impossible is a function that was copied into existence rather than designed for its job.</p>
<p><strong>Key takeaway:</strong> Before writing a function that wraps a library call, grep for the wrapper your codebase already has around that call — the fix usually belongs inside it. If the name you are about to define is one character away from a name already in the file, stop: that is not a naming problem, it is a sign you are duplicating an abstraction that exists.</p>
</section>
<section id="postscript-keeping-the-mistake-on-purpose" class="level2">
<h2 class="anchored" data-anchor-id="postscript-keeping-the-mistake-on-purpose">Postscript: keeping the mistake on purpose</h2>
<p>The second correction was not a correction at all. Asking for the wrong version to be committed <em>first</em>, so that the refactor exists as its own diff, treats the error as material rather than as something to erase before anyone sees it. The useful artifact is not the final code — it is the <code>+15 / −24</code> between two commits, which shows that the better version is also the smaller one.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>python</category>
  <category>github</category>
  <category>refactoring</category>
  <category>maintainability</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-09-03-fix-belongs-in-the-wrapper.html</guid>
  <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Five Hundred Lines to Replace print</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-09-03-reviewability-is-a-requirement.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A course-management script that drives a GitHub Enterprise server as an LMS: it creates a repo per student per assessment, seeds files, grants access and opens notification issues, across ~170 repos and 40 concurrent threads. Its only output was 103 bare <code>print()</code> calls to stdout. The only way to keep a record of a run was a shell redirect, and the result was unstructured prose – finding the eight students whose collaborator add failed meant grepping a 1,900-line file.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“in a separate branch so i can rebase it against main. can you make it such that the print statements also log to a file? something that is capturing all the features that make it easy to filter through status across each of the steps? i’d like to have a csv file that we can use to help sort and look for errors and students”</p>
</blockquote>
<p>A clear, reasonable request with a named deliverable: a CSV you can sort and filter by status and student.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude designed and built exactly that, thoroughly:</p>
<ul>
<li>a new <code>run_log.py</code> (387 lines) – stdlib logging with three handlers: stdout, a per-run <code>.log</code>, and a per-run <code>.csv</code> with 18 columns</li>
<li>a new <code>test_run_log.py</code> (160 lines) that hammers the logger from 40 threads</li>
<li><code>contextvars</code> carrying run/step/student/repo context, stamped onto every record by a logging <code>Filter</code>, so a helper deep in the call stack gets the right student without argument plumbing</li>
<li><code>extra={...}</code> on 98 call sites, <code>@_repo_scope</code> decorators on 10 methods, <code>with step(...)</code> blocks at 16 sites, and <code>copy_context()</code> per threadpool task so worker threads inherit the run context</li>
</ul>
<p>It was correct. The test passed with 4,202 CSV rows, asserting that context propagated into pool threads and did not leak between tasks. It handled real edge cases: pandas <code>NaN</code> in roster rows, filename sanitising, a per-row flush so a killed run still leaves a usable file.</p>
<p>Transplanted onto the target branch it came to <strong>+507/−135 in <code>main.py</code></strong>, plus 547 lines in two new files.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“why does the run_log.py file exist?”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong> Explained the design, and volunteered that the CSV was “a choice, not a necessity” worth questioning in review – while also disclaiming authorship, on the grounds that a different session had written it.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“you did write it in the work/plan/implmeentation process. you might be able to see it in a different conversation in this repo outside the worktree”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong> Found the original design session on disk and read it back. Two corrections followed: the work <em>was</em> Claude’s, and the CSV had been the user’s explicit opening request, not a flourish – so the “question it in review” advice was wrong and got retracted.</p>
<p><strong>Turn 3 — User:</strong></p>
<blockquote class="blockquote">
<p>“we assume we run all the main.py from the githublms root. is there a way to not have that file and test file?”</p>
</blockquote>
<p><strong>Turn 3 — Claude:</strong> Argued the module should stay separate: a sibling module raises at import time unless the cwd is the repo root, so keeping <code>run_log.py</code> standalone was what made it testable without the full dependency stack.</p>
<p><strong>Turn 4 — User:</strong></p>
<blockquote class="blockquote">
<p>“nobody runs this in a notebook. we run it as python main.py (or later uv run python main.py …)”</p>
</blockquote>
<p><strong>Turn 4 — Claude:</strong> Conceded. The module’s import-safety guarantee – install no handlers, touch no filesystem on import – existed to protect <code>import main</code> from a notebook, a caller that does not exist. The test verifying that guarantee was testing a promise nothing depended on. Two more pieces fell out as dead on a CLI-only path: an <code>ensure_configured()</code> that existed for repeated calls in one notebook interpreter, and a <code>shutdown()</code> documented as “test/notebook convenience only”.</p>
<p><strong>Turn 5 — User:</strong></p>
<blockquote class="blockquote">
<p>“yeah remove the run_log.py and the corresponding test file. i’m still unsure how the logging is not accurate”</p>
</blockquote>
<p><strong>Turn 5 — Claude:</strong> Answered the second half with two runnable demonstrations rather than an assertion. Without <code>copy_context()</code>, every threaded row lost its run identity; with a leaked context on a reused pool thread, a failure row was attributed to the <em>wrong student</em> – the console said only <code>ERROR: could not add collaborator</code>, and the file named a student whose add had succeeded. Then inlined the module and deleted both files.</p>
<p><strong>Turn 6 — User:</strong></p>
<blockquote class="blockquote">
<p>“if we ened to we can forgo the print statements in lieu of a logging statement, this way it shows up on the screen + file the same way, we don’t need the csv file”</p>
</blockquote>
<p><strong>Turn 6 — Claude:</strong> Flagged that this reversed the original ask, offered three shapes, and built the chosen one: the structured fields moved from CSV columns into a <code>key=value</code> prefix on each log line, so <code>grep 'status=failed'</code> replaced the pandas filter. One file per run instead of two.</p>
<p><strong>Turn 7 — User:</strong></p>
<blockquote class="blockquote">
<p>“ok but now all you did was move that code into main.py. why is the diff more complicated than just the logger.info() bits? why are there so many new functions? can we minimize the diff in this PR?”</p>
</blockquote>
<p><strong>Turn 7 — Claude:</strong> Measured instead of explaining. Of 507 added lines only <strong>108</strong> were the actual <code>print</code> → <code>logger</code> conversion. And of the 86 log calls carrying a status, <strong>49 already named the student or repo in the message text</strong>, while the 37 that did not were run-level lines with no student subject at all. The context machinery was largely duplicating identifiers the messages already contained. Stripped it: 11 <code>ContextVar</code>s, <code>_bind</code>, <code>student_ctx</code>, <code>repo_ctx</code>, <code>step</code>, <code>ContextFilter</code>, the loop wrappers, the decorators, and 15 <code>with</code> blocks. Down to +340/−110.</p>
<p><strong>Turn 8 — User:</strong></p>
<blockquote class="blockquote">
<p>“i think we can simplify this a lot by removing the extras bit, and the run level and context managers? we dont’ need that. let’s start over and do a very minimal pass of logging. this will make it easier to review we can add fanciness later. i take back my intial request it was too much new things that I can’t review”</p>
</blockquote>
<p><strong>Turn 8 — Claude:</strong> Pushed the existing work to an archive branch so nothing was destroyed, reset to <code>origin/main</code>, and rebuilt from scratch as a single commit.</p>
<p><strong>Turn 9 — User:</strong></p>
<blockquote class="blockquote">
<p>“i was expecting all the print statements to be replaced by logging. in the print statements let’s also keep the WARNING ERROR text in the text as well. so it’s a light touch on the diff”</p>
</blockquote>
<p><strong>Turn 9 — Claude:</strong> Made it a pure mechanical substitution – message text untouched, including the messages that label themselves “Warning:” or “FAILED”, so the level is additional rather than a replacement. Level per call site from one stated rule instead of 103 judgement calls.</p>
<p><strong>Turn 10 — User:</strong></p>
<blockquote class="blockquote">
<p>“the KEYERROR should be flagged as ERROR in the log not INFO”</p>
</blockquote>
<p><strong>Turn 10 — Claude:</strong> Fixed it, then treated it as a rule defect rather than a typo: the rule keys off the <em>first word</em>, so it misses any message whose signal sits later. Audited every remaining <code>logger.info</code> for problem wording anywhere in the text, found 15 candidates, and proposed the 5 genuine siblings – while noting that the “already exists … skipping” family is normal idempotent behaviour and should stay INFO.</p>
<p><strong>Turn 11 — User:</strong></p>
<blockquote class="blockquote">
<p>“promote”</p>
</blockquote>
<p><strong>Turn 11 — Claude:</strong> Applied them. While verifying, noticed the PR had grown a fourth file: an unrelated dependency-file edit swept in by <code>git add -A</code> without checking <code>git status</code> first. Rebuilt the commits without it.</p>
<p><strong>Turn 12 — User:</strong></p>
<blockquote class="blockquote">
<p>“finally in the print statement are we able to easily add the timestamp and log status in the print statement without too much changes?”</p>
<p>“2026-09-03 23:13:11 INFO”</p>
</blockquote>
<p><strong>Turn 12 — Claude:</strong> One line: point the console handler at the same formatter as the file.</p>
<p><strong>Turn 13 — User:</strong></p>
<blockquote class="blockquote">
<p>“ok this looks good much simplier and easier to review”</p>
</blockquote>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>Three files, four commits, <strong>+156/−105</strong> – of which 103 added lines are the converted calls themselves:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">def</span> setup_logging(mode<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">None</span>, course<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">None</span>, name<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">None</span>, log_dir<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>LOG_DIR):</span>
<span id="cb1-2">    logger.setLevel(logging.DEBUG)</span>
<span id="cb1-3">    logger.propagate <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span></span>
<span id="cb1-4"></span>
<span id="cb1-5">    formatter <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> logging.Formatter(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%(asctime)s</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%(levelname)-7s</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%(message)s</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span>,</span>
<span id="cb1-6">                                  datefmt<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"%Y-%m-</span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%d</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> %H:%M:%S"</span>)</span>
<span id="cb1-7"></span>
<span id="cb1-8">    console <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> logging.StreamHandler(sys.stdout)</span>
<span id="cb1-9">    console.setFormatter(formatter)</span>
<span id="cb1-10">    logger.addHandler(console)</span>
<span id="cb1-11"></span>
<span id="cb1-12">    os.makedirs(log_dir, exist_ok<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>)</span>
<span id="cb1-13">    stem <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"_"</span>.join(<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">str</span>(part) <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> part <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span></span>
<span id="cb1-14">                    (datetime.now().strftime(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"%Y%m</span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%d</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">T%H%M%S"</span>), mode, course, name) <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> part)</span>
<span id="cb1-15">    path <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> os.path.join(log_dir, re.sub(<span class="vs" style="color: #20794D;
background-color: null;
font-style: inherit;">r"</span><span class="pp" style="color: #AD0000;
background-color: null;
font-style: inherit;">[^</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">\w</span><span class="pp" style="color: #AD0000;
background-color: null;
font-style: inherit;">.-]</span><span class="vs" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"_"</span>, stem) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">".log"</span>)</span>
<span id="cb1-16"></span>
<span id="cb1-17">    log_file <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> logging.FileHandler(path, mode<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"w"</span>, encoding<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"utf-8"</span>)</span>
<span id="cb1-18">    log_file.setFormatter(formatter)</span>
<span id="cb1-19">    logger.addHandler(log_file)</span>
<span id="cb1-20"></span>
<span id="cb1-21">    logger.info(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Logging this run to </span><span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">%s</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span>, path)</span>
<span id="cb1-22">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span> path</span></code></pre></div></div>
<p>Everything else is <code>print(</code> → <code>logger.info(</code> / <code>logger.warning(</code> / <code>logger.error(</code>, message text unchanged.</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th></th>
<th>first version</th>
<th>shipped</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>new files</td>
<td>2 (547 lines)</td>
<td>0</td>
</tr>
<tr class="even">
<td><code>main.py</code></td>
<td>+507/−135</td>
<td>+154/−104</td>
</tr>
<tr class="odd">
<td>of which, the actual conversion</td>
<td>108</td>
<td>103</td>
</tr>
<tr class="even">
<td>new functions/classes</td>
<td>20</td>
<td>1</td>
</tr>
</tbody>
</table>
<p>The discarded version was pushed to an archive branch and linked from the pull request, so the structured logging remains available as a later increment.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong> The engineering. The design solved a real problem correctly, was verified rather than asserted, and handled genuine edge cases that a quick version would have missed. The <code>contextvars</code> approach is the right way to attribute log records without threading arguments through a hundred helpers – given that you need that attribution at all. And when finally asked to justify the size, Claude measured rather than argued: the 108-of-507 ratio and the 49-of-86 finding are what ended the debate, and both were cheap to compute at any point.</p>
<p><strong>What required human expertise:</strong> Knowing that <strong>reviewability is a hard requirement, not a nice-to-have</strong>. The user reviews line by line on a pull request; a diff he cannot check is a diff he cannot merge, so a large correct change is worth less to him than a small one he can approve today. He also knew things about his own project that no amount of reading the code would reveal – nobody imports this module, nobody runs it from a notebook, it is always invoked from the repo root – and each of those facts demolished one of Claude’s justifications for the extra structure.</p>
<p><strong>Why Claude missed it:</strong></p>
<ol type="1">
<li><p><em>Optimised for the stated feature, not the delivery constraint.</em> “I’d like a CSV to filter by status and student” was read as a specification to satisfy completely. The clause before it – “in a separate branch so i can rebase it against main” – said this had to land as a reviewable change, and that got treated as logistics rather than as a design constraint with teeth.</p></li>
<li><p><em>Every addition was locally justified, so the total was never questioned.</em> Context propagation solves a real problem. Per-row flush protects a killed run. NaN coercion handles actual roster data. No individual step felt excessive, so complexity accreted without ever reaching a point where Claude asked whether the whole apparatus was worth its review cost.</p></li>
<li><p><em>Never measured the thing it was building.</em> The killer fact – that 49 of 86 messages already named the student, making the attribution machinery largely redundant – took one script to establish, and Claude only ran it when asked in turn 7. It could have been run before writing a line. Building the machinery first and checking its value never is the wrong order.</p></li>
<li><p><em>Verification created false confidence.</em> A 4,202-row passing test made the machinery feel earned. But a test proves code is <em>correct</em>, never that it should <em>exist</em>, and demonstrating that an unnecessary abstraction works makes it harder to delete, not easier.</p></li>
<li><p><em>Defended three times before measuring once.</em> Asked why the module existed, Claude gave a reason (cwd dependence); knocked down, it gave another (dependency-free testing); knocked down, another (import safety). Each was true in general and irrelevant here. Reasoning from software-engineering principles instead of from this project’s actual usage produced three confident answers that a single question about how the tool is run invalidated.</p></li>
<li><p><em>Framing as a transplant suppressed the question.</em> This session’s job was “cherry-pick these commits onto main,” so the existing design was treated as a given to be preserved through 20 merge conflicts. A task framed as moving work makes it unnatural to ask whether the work should land at all – but that was the question worth asking on conflict number one.</p></li>
</ol>
<p><strong>Key takeaway:</strong> Before building, ask what the reviewer has to be able to check, and size the change to fit – then count how many of your added lines are the actual change versus machinery supporting it, and cut if the machinery wins. A small correct change that merges today beats a complete one that stalls in review.</p>
</section>
<section id="postscript-retracting-your-own-request-is-a-legitimate-move" class="level2">
<h2 class="anchored" data-anchor-id="postscript-retracting-your-own-request-is-a-legitimate-move">Postscript: retracting your own request is a legitimate move</h2>
<p>The turn that unblocked this was the user withdrawing his own opening ask: <em>“i take back my intial request it was too much new things that I can’t review.”</em> Nothing about the original request was unreasonable, and the thing built from it worked. It was still right to abandon it, because the constraint that mattered only became visible once the diff existed.</p>
<p>Two habits made that cheap. The discarded work went to an archive branch rather than the bin, so “we can add fanciness later” stayed true rather than becoming a consolation. And the rebuild started from <code>origin/main</code> rather than trying to subtract the machinery from the existing branch – starting over was less work, and produced a cleaner history, than unwinding it commit by commit.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>python</category>
  <category>refactoring</category>
  <category>over-engineering</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-09-03-reviewability-is-a-requirement.html</guid>
  <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Introduce It Before You Lean On It</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-26-introduce-before-you-rely-on-it.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>Drafting <code>lectures/7a-virtual-environments-uv.qmd</code> for the DSCI 521 textbook — a chapter teaching Python virtual environments with <code>uv</code>.</p>
<p>This is the <em>second</em> correction from the same reviewer, on the same file, in the same session. The first one (<a href="../posts/2026-08-26-venv-is-a-module.html">venv is a module</a>) was a factual error. This one is not. Everything in the paragraph below is true.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>you have so much lectures/* context to use as a writing sample. first describe the writing style from all these samples and then use the writing sample/style to draft a lectures 7a-virtual-environments-uv.qmd</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>The chapter opened with a section naming the pieces of the Python ecosystem — Python, <code>venv</code>, <code>pip</code>, PyPI, <code>pyproject.toml</code> — and then, in the very next paragraph, this:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb1-1"><span class="an" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">Doing this by hand means running several separate tools in the right order:</span></span>
<span id="cb1-2">create a virtual environment with <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`python -m venv`</span>,</span>
<span id="cb1-3">remember to activate it,</span>
<span id="cb1-4">install packages with <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`pip`</span>,</span>
<span id="cb1-5">and then keep a <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`requirements.txt`</span> up to date yourself.</span></code></pre></div></div>
<p><code>requirements.txt</code> had not been mentioned anywhere before that sentence.</p>
<p>Some 350 lines later, a warning box opened with:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb2-1">You may have seen the older Python approach of running</span>
<span id="cb2-2"></span>
<span id="cb2-3">pip freeze &gt; requirements.txt</span></code></pre></div></div>
<p>Also never introduced. Note the hedge — <em>you may have seen</em> — which is what you write when you do not know your reader.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User</strong> <em>(with the <code>pyproject.toml</code> bullet selected in the editor)</em><strong>:</strong></p>
<blockquote class="blockquote">
<p>we need to also talk about the differences between the toml file and the requirements file. the following paragraph just brings up requirements without any context and assumes a lot from the student</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Gave <code>requirements.txt</code> its own bullet in the ecosystem list, rewrote the <code>pyproject.toml</code> bullet to say what it <em>adds</em> (structure — so it can also hold the project name, the Python version, and dev-only packages), and added a new subsection between the list and the paragraph that had been leaning on the term.</p>
<p>The subsection makes the difference concrete by putting two files side by side — one written by hand, one produced by <code>pip freeze</code>:</p>
<pre><code>pandas                          iniconfig==2.3.0
palmerpenguins                  numpy==2.5.2
                                packaging==26.3
                                palmerpenguins==0.1.6
                                pandas==3.0.5
                                ...</code></pre>
<blockquote class="blockquote">
<p>Those two files describe the same project. Both are called <code>requirements.txt</code>, both are a list of packages, and nothing inside either one tells you which kind you are holding.</p>
</blockquote>
<p>The <code>pip freeze</code> column is real output from the throwaway project built earlier in the session, which is what makes it useful: only two of those ten packages were ever asked for. <code>numpy</code> and <code>six</code> are dependencies of <code>pandas</code>; <code>pytest</code> was installed deliberately but has nothing to do with the analysis.</p>
<p>Also converted the later warning box from an assumption (“You may have seen the older Python approach”) into a callback (“This is also where the <code>pip freeze</code> file we saw earlier falls down”).</p>
<p>The new bullet Claude wrote opened like this:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb4-1"><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">- </span>**`requirements.txt`** is the older way of recording what a project needs</span>
<span id="cb4-2">...</span>
<span id="cb4-3"><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">- </span>**`pyproject.toml`** is the file that replaced it.</span></code></pre></div></div>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>requiremeents.txt isn’t an “older way” it’s built in without any more additional tools. correct me if i’m wrong, unless pip can read a toml file to install</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>Checked instead of conceding, and the check turned up more than either party had assumed. <code>pip</code> <em>can</em> read a <code>pyproject.toml</code>, and two flags had shipped for exactly this:</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th>flag</th>
<th>what it does</th>
<th>shipped</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><code>pip install --group dev</code></td>
<td>installs a <a href="https://peps.python.org/pep-0735/">PEP 735</a> dependency group from <code>pyproject.toml</code></td>
<td>pip 25.1, Apr 2025</td>
</tr>
<tr class="even">
<td><code>pip install --only-deps .</code></td>
<td>installs <code>[project].dependencies</code> <em>without</em> installing the project</td>
<td>pip 26.2, Jul 2026</td>
</tr>
</tbody>
</table>
<p>Both were verified against a bare <code>pyproject.toml</code> with no <code>[build-system]</code>. <code>--only-deps</code> really did skip installing the project itself. It had shipped <strong>one month</strong> before this conversation.</p>
<p>So “the older way” was wrong, “the file that replaced it” was worse, and old-versus-new was the wrong axis in the first place. Rewrote the pair around what each file is <em>for</em> — <code>requirements.txt</code> is an instruction to install some things, <code>pyproject.toml</code> is a description of a project — and added a note recording that <code>pip</code> has caught up on declaring but still has no lockfile.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p><code>requirements.txt</code> is introduced as a first-class item in the ecosystem list, then given a subsection — <em>What a <code>requirements.txt</code> does not tell you</em> — that uses the comparison to land the chapter’s central vocabulary in the reader’s hands before anything depends on it:</p>
<ul>
<li>what a project <strong>declares</strong> — the packages you asked for</li>
<li>what a project <strong>locks</strong> — the packages you actually got, at exact versions</li>
</ul>
<blockquote class="blockquote">
<p>A <code>requirements.txt</code> can be either one of those, and never says which. <code>uv</code> gives the two jobs two separate files instead: <code>pyproject.toml</code> declares, and <code>uv.lock</code> locks.</p>
</blockquote>
<p>The correction turned a cold reference into the place where the chapter’s main idea gets introduced.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>Everything it said about <code>requirements.txt</code> was accurate. <code>pip freeze</code> really does dump the whole environment; you really do have to maintain the file by hand. Nothing had to be retracted. A reviewer checking the draft for <em>correctness</em> would have passed it — which is the entire problem.</p>
<p><strong>What required human expertise:</strong></p>
<p>Reading the sentence as the student, not as someone who already knows.</p>
<p>The MDS 2026-27 cohort has only ever used <code>uv</code>. They have been running <code>uv sync</code> on lab assignments since week one and most of them have never seen a <code>requirements.txt</code> in their lives. Written for that reader, “keep a <code>requirements.txt</code> up to date yourself” is not a relatable pain point — it is an undefined noun dropped into the middle of a sentence that was supposed to be motivating something.</p>
<p>An argument built on shared pain only works on people who felt the pain.</p>
<p><strong>Why Claude missed it:</strong></p>
<p><em>It did not propagate a fact it had already been given.</em> Two turns earlier, the same reviewer had corrected Claude about this exact cohort — that they use <code>uv sync</code> in their weekly homework, not just once during setup. Claude updated the opening section where that correction landed and never revisited any other part of the chapter in light of it. A correction was absorbed as a patch to one location rather than as a fact about the reader that invalidates assumptions everywhere in the document. The information needed to catch this was already in the conversation.</p>
<p><em>Fluency hides the gap.</em> “and then keep a <code>requirements.txt</code> up to date yourself” is a smooth, idiomatic sentence. A cold reference does not produce an error or an awkward phrase — it produces prose that reads perfectly to anyone who already knows the term, which is exactly the population doing the reviewing. You cannot feel the gap by rereading your own writing, because you know.</p>
<p><em>Optimizing for the argument instead of the reader.</em> That paragraph’s job was to make the manual workflow look painful so that <code>uv</code> would look good by contrast. <code>requirements.txt</code> was being used as <em>evidence in an argument</em>. Claude reached for it rhetorically without asking whether the reader could cash the reference.</p>
<p><strong>The two corrections in this session were different species.</strong> The first was wrong and could be settled with <code>which venv</code>. This one was right, and no amount of fact-checking would have surfaced it. It took someone who knows what this specific room of students has and has not seen. That is the difference between a technical review and a teaching review, and only the second one catches this.</p>
<p><strong>And then the fix was wrong too.</strong></p>
<p>The second correction is worth keeping in the same document, because it is a <em>different</em> failure that happened while repairing the first one.</p>
<p>Claude reached for “older way … the file that replaced it” because that is the narrative shape a chapter about a new tool wants. Old thing, new thing, new thing wins. It made the argument easier to write. It was also false, and falsifiable in about ninety seconds with <code>pip install --help</code>, which Claude did not run because the claim did not feel like the kind of thing that needed checking. Framing claims rarely do. That is what makes them dangerous: a wrong fact gets checked, a wrong frame gets absorbed.</p>
<p>The correction improved the chapter’s argument, not just its accuracy. A case for <code>uv</code> resting on “pip cannot read <code>pyproject.toml</code>” was already false when it was written and would have aged badly in public. A case resting on “pip has no lockfile” is true and durable. <strong>The wrong framing had been hiding a weak argument</strong> — which is usually why a framing is wrong.</p>
<p><strong>Key takeaway:</strong></p>
<p>Correct is not the same as teachable — check every term against what <em>your</em> reader has actually met, and when someone tells you something about your audience, re-read the whole document in that light, not just the paragraph you were editing.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>teaching</category>
  <category>writing</category>
  <category>python</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-26-introduce-before-you-rely-on-it.html</guid>
  <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: A Program, or a Module?</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-26-venv-is-a-module.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>Drafting a new chapter for the DSCI 521 textbook, <code>lectures/7a-virtual-environments-uv.qmd</code>, teaching Python virtual environments with <code>uv</code>.</p>
<p>The chapter has a section called “The Python packaging ecosystem” whose whole job is to name the moving parts before <code>uv</code> is introduced, so students can tell them apart. It exists to serve learning objective 2 of the chapter, which Claude had drafted an hour earlier:</p>
<blockquote class="blockquote">
<ol start="2" type="1">
<li>Differentiate Python, PyPI, <code>pip</code>, <code>venv</code>, and <code>uv</code></li>
</ol>
</blockquote>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>you have so much lectures/* context to use as a writing sample. first describe the writing style from all these samples and then use the writing sample/style to draft a lectures 7a-virtual-environments-uv.qmd</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude drafted the chapter, including this bullet:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb1-1"><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">- </span>**Python** is the language,</span>
<span id="cb1-2">  and it ships with a program called <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`venv`</span></span>
<span id="cb1-3">  that can create an isolated folder of packages.</span></code></pre></div></div>
<p>and, a paragraph later:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb2-1"><span class="an" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">Doing this by hand means running several separate programs in the right order:</span></span>
<span id="cb2-2">create a virtual environment with <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`venv`</span>,</span>
<span id="cb2-3">remember to activate it,</span>
<span id="cb2-4">...</span></code></pre></div></div>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“<strong>Python</strong> is the language, and it ships with a program called <code>venv</code> that can create an isolated folder of packages.” is it a “program” or a module?</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Rather than immediately agreeing and rewording, checked it:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb3-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">$</span> which venv</span>
<span id="cb3-2"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">venv</span> not found</span>
<span id="cb3-3"></span>
<span id="cb3-4"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">$</span> python3 <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-c</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"import venv; print(venv.__file__)"</span></span>
<span id="cb3-5"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">/opt/homebrew/.../python3.14/venv/__init__.py</span></span>
<span id="cb3-6"></span>
<span id="cb3-7"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">$</span> python3 <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-m</span> venv <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--help</span></span>
<span id="cb3-8"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">usage:</span> python3.14 <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-m</span> venv <span class="pp" style="color: #AD0000;
background-color: null;
font-style: inherit;">[-</span><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">h</span><span class="pp" style="color: #AD0000;
background-color: null;
font-style: inherit;">]</span> ...</span></code></pre></div></div>
<p>Confirmed: <code>venv</code> is a standard library <strong>module</strong>. There is no <code>venv</code> executable anywhere on <code>PATH</code>. It can only be run <em>through</em> an interpreter, as <code>python -m venv</code>.</p>
<p>Checked <code>pip</code> for contrast and found it is the opposite case — both an importable module <em>and</em> a console script installed on <code>PATH</code>:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb4-1"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">$</span> which pip3</span>
<span id="cb4-2"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">/opt/homebrew/bin/pip3</span></span>
<span id="cb4-3"></span>
<span id="cb4-4"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">$</span> python3 <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-c</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"import pip; print(pip.__file__)"</span></span>
<span id="cb4-5"><span class="ex" style="color: null;
background-color: null;
font-style: inherit;">/opt/homebrew/lib/python3.14/site-packages/pip/__init__.py</span></span></code></pre></div></div>
<p>Fixed both mentions, and expanded the bullet rather than just swapping the noun, because the module/program distinction turned out to be the exact thing the section was supposed to be teaching.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb5" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb5-1"><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">- </span>**Python** is the language.</span>
<span id="cb5-2">  Its standard library includes a module called <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`venv`</span></span>
<span id="cb5-3">  that can create an isolated folder of packages.</span>
<span id="cb5-4">  There is no <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`venv`</span> command to type:</span>
<span id="cb5-5">  it is a module rather than a program,</span>
<span id="cb5-6">  so you run it *through* an interpreter,</span>
<span id="cb5-7">  as <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`python -m venv &lt;folder&gt;`</span>.</span>
<span id="cb5-8">  Which interpreter you run it through</span>
<span id="cb5-9">  is what decides which Python ends up in the new environment.</span>
<span id="cb5-10"><span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">- </span>**`pip`** is the package installer that comes with Python.</span>
<span id="cb5-11">  ...</span>
<span id="cb5-12">  Unlike <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`venv`</span>, <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`pip`</span> does install a command of its own,</span>
<span id="cb5-13">  so both <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`pip install`</span> and <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`python -m pip install`</span> work.</span></code></pre></div></div>
<p>And the knock-on mention:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb6" style="background: #f1f3f5;"><pre class="sourceCode markdown code-with-copy"><code class="sourceCode markdown"><span id="cb6-1"><span class="an" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">Doing this by hand means running several separate tools in the right order:</span></span>
<span id="cb6-2">create a virtual environment with <span class="in" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">`python -m venv`</span>,</span></code></pre></div></div>
<p>The added sentence about <em>which interpreter you run it through</em> also does downstream work: it sets up why <code>uv python pin</code> exists later in the same chapter.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>The list of ecosystem pieces was correct and complete, and <code>venv</code>’s <em>function</em> was described accurately — it does create an isolated folder of packages, and it does ship with Python. Nothing about the paragraph was misleading about what <code>venv</code> does.</p>
<p><strong>What required human expertise:</strong></p>
<p>Knowing that the imprecision has a <em>consequence for a student</em>. Calling <code>venv</code> a program implies there is a <code>venv</code> command, so a student reads the sentence, opens a terminal, types <code>venv</code>, and gets <code>command not found</code> — in week four, in a course about the command line, while learning a topic they are already unsure of.</p>
<p>That is not textbook knowledge about Python. It is knowledge about what students do with a sentence, which comes from having watched them do it.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Three things stacked up.</p>
<p><em>Colloquial usage.</em> Everyone says “use venv to create an environment.” The noun almost never gets examined, so the common phrasing and the accurate phrasing had drifted apart in the text Claude learned from.</p>
<p><em>Parallel construction.</em> The neighbouring bullets were <code>pip</code> and <code>uv</code>, which genuinely <em>are</em> programs. Claude was writing a rhythmic parallel list, and the grammatical slot pulled <code>venv</code> into the same category as its neighbours. The sentence was optimized for flow, not for the accuracy of one category noun.</p>
<p><em>No weighting for the medium.</em> “A program called <code>venv</code>” costs nothing in a Slack message. In a textbook it manufactures a support ticket. Claude was writing prose that read well without asking what this particular reader would <em>do</em> with it.</p>
<p>The sharpest part: Claude had drafted the learning objective “Differentiate Python, PyPI, <code>pip</code>, <code>venv</code>, and <code>uv</code>” earlier in the same session, then blurred that exact distinction in the prose meant to teach it. Local fluency beat consistency with a document Claude had written itself, minutes before.</p>
<p><strong>Worth noting — the correction was a question, not an assertion.</strong></p>
<p>The user did not say “that’s wrong, <code>venv</code> is a module.” They asked which one it was.</p>
<p>That form did two things. It meant the reviewer did not need to already know the answer — noticing that a word was doing suspicious work was enough to catch it. And it invited verification instead of compliance: an assertion would most likely have produced instant agreement and a reword, which is indistinguishable from a model simply going along with whoever spoke last. A question produced <code>which venv</code> and an actual empty result first.</p>
<p><strong>Key takeaway:</strong></p>
<p>You do not need to know the right answer to catch a wrong one — if a word looks like it is doing suspicious work, ask which it is, because a question gets you evidence where a correction gets you agreement.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>teaching</category>
  <category>writing</category>
  <category>python</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-26-venv-is-a-module.html</guid>
  <pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: When the Checker Deletes Its Own Output</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-checker-deletes-its-output.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A setup-check repository for the UBC Master of Data Science program. Incoming students clone it and run <code>make</code> to confirm their machine can turn a source document into a PDF they can hand in. A Makefile renders each fixture by every available route, so that a student whose toolchain is broken finds out here rather than the night an assignment is due.</p>
<p>One of those fixtures, <code>check-quarto.qmd</code>, is rendered three ways from the single source file: to PDF through LaTeX, to PDF through Typst, and to HTML. Typst matters because it needs no LaTeX at all and, unlike the LaTeX route, reproduces emoji and literal Greek letters — so it is the route a student is told to use for a document containing them.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<p>Add the Typst route to the build, alongside the LaTeX PDF target that was already there.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude added a <code>typst</code> target to the Makefile and ran the build. <code>make -k all</code> reported success. Eight outputs were expected. Seven were on disk.</p>
<p><code>check-quarto.pdf</code> was not, and nothing anywhere said so. No non-zero exit, no error, no warning. The build log even contained the LaTeX render finishing normally:</p>
<pre><code>Output created: check-quarto.pdf</code></pre>
<p>Claude isolated it by rendering the formats one at a time and listing the directory between each:</p>
<pre><code>render --to pdf     -&gt; check-quarto.pdf         exists
render --to html    -&gt; check-quarto.pdf         still exists
render --to typst   -&gt; check-quarto.pdf         GONE</code></pre>
<p>The cause: Quarto treats the LaTeX PDF and the Typst PDF as the <em>same output</em> for a given input, because both are <code>.pdf</code> produced from <code>check-quarto.qmd</code>. Rendering one removes the other. Silently — the file simply is not there afterwards.</p>
<p>Worth recording, because it is the sharper half of this note: Claude had already seen this symptom earlier in the same session. It observed that <code>check-quarto.pdf</code> succeeded in run 1 and failed in run 2, wrote that down, and moved on, because it was mid-task on something else and the inconsistency looked like noise.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<p>Pointed at the gap directly — the build is green and the file is not on disk.</p>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Having found the collision, Claude proposed a fix at the shell level: copy <code>check-quarto.qmd</code> to a second, temporary filename, render Typst from the copy, then delete the copy. Two inputs, two outputs, no collision.</p>
<p>It worked. It was also order-independent, so it would survive <code>make -j</code>. But it added a temporary file to the Makefile and a matching line to <code>clean</code>, and the two PDFs still had names that said nothing about which engine produced them.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>we should have a check-quarto-latex.pdf and a check-quarto-typst-pdf file if that helps. the qmd file would have different output formats and file names</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>That is the tool-native answer, and it removes the problem rather than working around it. Quarto lets a single document declare several formats in its YAML, each with its own <code>output-file</code>:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode yaml code-with-copy"><code class="sourceCode yaml"><span id="cb3-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">format</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span></span>
<span id="cb3-2"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">  # One source, three routes, each to its own file. Quarto treats the LaTeX PDF</span></span>
<span id="cb3-3"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">  # and the Typst PDF as the same output for a given input unless they are named</span></span>
<span id="cb3-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">  # apart, and rendering one then deletes the other.</span></span>
<span id="cb3-5"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">  </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">pdf</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span></span>
<span id="cb3-6"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">    </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">output-file</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> check-quarto-latex.pdf</span></span>
<span id="cb3-7"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">  </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">typst</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span></span>
<span id="cb3-8"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">    </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">output-file</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> check-quarto-typst.pdf</span></span>
<span id="cb3-9"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">  </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">html</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span></span>
<span id="cb3-10"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">    </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">output-file</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> check-quarto.html</span></span></code></pre></div></div>
<p>Verified by re-running the same bisection in the reverse order that had destroyed the file before — <code>--to typst</code>, then <code>--to pdf</code>, then <code>--to html</code>. All three outputs coexisted. The temp-copy hack was deleted.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>The per-format <code>output-file</code> block above, plus Makefile targets that name the real artifacts:</p>
<pre class="make"><code>pdf: check-quarto-latex.pdf check-notebook.pdf check-rmarkdown.pdf
typst: check-quarto-typst.pdf</code></pre>
<p>No temporary file, nothing extra in <code>clean</code>, and the outputs are now named for what produced them — which turns out to matter beyond the bug, because the whole point of keeping both PDF routes is that they do not handle the same characters. <code>check-quarto.pdf</code> answered “did a PDF get made?”. <code>check-quarto-latex.pdf</code> and <code>check-quarto-typst.pdf</code> answer “did <em>this route</em> work?”, which is the question the repository exists to ask.</p>
<p>The reason is recorded where the next person will hit it, in the YAML comment above and again next to the Makefile target, so that nobody later “tidies up” the names and silently reintroduces the collision.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>The diagnosis. A file that is missing with no error and no failing exit code gives you nothing to grep for, and Claude did the one thing that works: stop reasoning about it, run the renders one at a time, and list the directory between each until the disappearance has a specific cause. Three commands, and an unexplained absence became a reproducible rule about how the tool treats output paths. The fix it then proposed was correct and even handled parallel builds.</p>
<p><strong>What required human expertise:</strong></p>
<p>Knowing that Quarto supports multiple formats in one document with per-format <code>output-file</code>. That is not a deduction from the symptom; it is a fact about the tool, and the user had it. One sentence, and the fix moved from the shell to the document — which is also where it becomes self-documenting, because the reason now lives in the file that causes the behaviour.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Claude was solving the problem it had just characterised. It had framed the bug as “two renders collide on one path”, and the shell offers an obvious lever for that: change the input path. Working around a tool from outside is nearly always available, so it is nearly always the first thing found — whereas using the tool’s own mechanism requires knowing the mechanism exists, and there is no signal in a collision that tells you to go looking for one.</p>
<p>The earlier miss is the more instructive one. Claude saw the file appear in one run and vanish in the next, and classified it as flakiness to revisit later. That instinct is usually reasonable and here was exactly backwards: the artifact behaving non-deterministically was the <em>verification harness</em>. Its entire job is to tell a student which render routes work on their machine. A route that produces nothing while the build reports success is precisely the failure this project exists to catch — and it was in the detection apparatus itself. Claude deprioritised it because it was mid-task, which is a scheduling decision, not a judgment about severity, and no one had told it that inconsistency in a checker outranks whatever else is in flight.</p>
<p><strong>Key takeaway:</strong></p>
<p>When the thing that verifies your work behaves inconsistently, stop and explain it before trusting any of its output — and when a tool creates the problem, look for the tool’s own mechanism before reaching for a shell-level workaround.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>quarto</category>
  <category>pdf</category>
  <category>testing</category>
  <category>debugging</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-checker-deletes-its-output.html</guid>
  <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Install the Dependency, Don’t Borrow a Copy of It</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-install-dont-borrow-pandoc.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>The UBC MDS install guides for the incoming cohort — around 100 students across macOS, Windows and Ubuntu — and the <code>check-setup-mds.sh</code> script students run to prove their install works.</p>
<p>The stack had recently migrated from conda to <code>uv</code> for Python. While testing that PDF rendering worked across all three routes students actually use — <code>quarto render</code>, <code>rmarkdown::render()</code>, and <code>jupyter nbconvert</code> — renders from a plain terminal began failing with <code>pandoc … not found</code>. I framed this as pandoc having become a hard requirement of the new uv-based stack.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“how did the r markdown rendering work in the previous install instructions? pandoc was never really a hard requirement back then. why is it all of a sudden becoming a hard requirement now?”</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>The challenge was correct and my framing was wrong, which I established by testing rather than asserting. <strong>pandoc did not become a hard requirement — it was always required, and it used to be satisfied twice over.</strong></p>
<p>Two independent suppliers existed in the conda era:</p>
<ol type="1">
<li><p><strong>conda packaged the binary.</strong> Verified against conda-forge: <code>nbconvert</code> depends on <code>nbconvert-pandoc</code>, which ships the pandoc <em>binary</em>. So <code>conda install jupyterlab …</code> put pandoc into the auto-activated <code>base</code> environment — on <code>PATH</code> globally, invisibly, for everything.</p></li>
<li><p><strong>The IDE exported it.</strong> RStudio sets <code>RSTUDIO_PANDOC</code> when it launches R, and <code>rmarkdown</code> honours it. Verified behaviourally:</p>
<pre><code>bare PATH, no RSTUDIO_PANDOC  → find_pandoc(): version 0   (NOT FOUND)
RSTUDIO_PANDOC set            → find_pandoc(): version 3.10</code></pre></li>
</ol>
<p>Corroborating evidence that the team already knew supplier 2 was load-bearing: the conda-era check script hunted for pandoc through eight hard-coded Quarto and RStudio bundle paths, and its list of required programs contained <strong>no <code>pandoc</code> entry at all</strong> — it never checked <code>PATH</code>, because R got pandoc from a bundle and Python got it from conda.</p>
<p><strong>What actually changed:</strong> <code>uv</code> installs Python wheels, and pandoc is a Haskell binary. uv cannot package it the way conda could, so supplier 1 vanished. The guides replaced it with a <code>PATH</code> line in <code>~/.bash_profile</code> pointing at Quarto’s bundled copy — <strong>one supplier where there had been two</strong>, and that one downstream of a <code>chsh -s /bin/bash</code> step with no verification anywhere.</p>
<p>Good diagnosis. Then I proposed fixes, and every one of them was downstream of the problem.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“can we use uv tool or uvx to install pandoc?”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Tested rather than guessed, and the finding was genuinely interesting: <code>pypandoc_binary</code> does ship a real pandoc (verified: <code>pandoc 3.9</code>, runs), with wheels for macOS x86_64 and arm64, Linux x86_64/aarch64/musl, and Windows amd64. Then three reasons it does not help:</p>
<ul>
<li><strong>It exposes no <code>pandoc</code> command.</strong> Its only console script is <code>pypandoc</code>; the binary is buried at <code>site-packages/pypandoc/files/pandoc</code>. nbconvert shells out to <code>pandoc</code> on <code>PATH</code>, so this changes nothing without <em>another</em> <code>PATH</code> edit — the exact fragility we were trying to remove.</li>
<li><strong>Two pandocs, different versions</strong>, with nothing to tell you which one wins the <code>PATH</code> race.</li>
<li><strong><code>uv</code> installed the x86_64 wheel on an arm64 machine</strong>, even though an arm64 wheel exists. A pandoc under Rosetta, on machines where Rosetta may not be installed, is a worse failure than the one we had.</li>
</ul>
<p><code>uvx</code>/<code>uv tool</code> were additionally wrong: they install into an isolated tool environment, not the project <code>.venv</code>, so <code>uv run jupyter nbconvert</code> would not see it. I countered with symlinking Quarto’s bundled pandoc into <code>.venv/bin</code>.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“can we get away with the current system of having quarto and rstudio installed for pandoc linking?”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>Tested the student-with-a-broken-<code>PATH</code>-edit case directly. (My first attempt at this test was broken — <code>uv</code> was not on the bare <code>PATH</code>, so a failure I read as “pandoc missing” was actually “uv not found”.) Corrected results:</p>
<table class="caption-top table">
<colgroup>
<col style="width: 33%">
<col style="width: 33%">
<col style="width: 33%">
</colgroup>
<thead>
<tr class="header">
<th>route</th>
<th>pandoc source</th>
<th>works without the <code>PATH</code> edit?</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td><code>quarto render</code> anywhere</td>
<td>Quarto’s own bundle, resolved internally</td>
<td>yes</td>
</tr>
<tr class="even">
<td>R inside RStudio</td>
<td><code>RSTUDIO_PANDOC</code></td>
<td>yes</td>
</tr>
<tr class="odd">
<td>R inside Positron</td>
<td><code>RSTUDIO_PANDOC</code></td>
<td>yes</td>
</tr>
<tr class="even">
<td><code>Rscript</code> from a terminal / <code>make</code></td>
<td>nothing sets it; <code>find_pandoc()</code> → <code>0</code></td>
<td><strong>no</strong></td>
</tr>
<tr class="odd">
<td><code>jupyter nbconvert</code> from a terminal</td>
<td>shells out to <code>pandoc</code> on <code>PATH</code></td>
<td><strong>no</strong></td>
</tr>
</tbody>
</table>
<p>Positron turned out to behave identically to RStudio, verified in its own source rather than assumed:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode ts code-with-copy"><code class="sourceCode typescript"><span id="cb2-1"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// Advertise the location of the Pandoc executable.</span></span>
<span id="cb2-2"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> (pandocPath) {</span>
<span id="cb2-3">    collection<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">replace</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'RSTUDIO_PANDOC'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> pandocPath)<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">;</span></span>
<span id="cb2-4">}</span></code></pre></div></div>
<p>Also verified: <code>quarto render --to pdf</code> succeeds with no pandoc anywhere on <code>PATH</code>, so the <code>PATH</code> line was never doing anything for Quarto in the first place.</p>
<p>My proposal: a Makefile block that exports <code>RSTUDIO_PANDOC</code> from whichever IDE bundle exists and prepends that directory to <code>PATH</code> for nbconvert. Clever, uses the sanctioned binary at the sanctioned version — and fixes exactly one entry point.</p>
<p><strong>Turn 3 — User:</strong></p>
<blockquote class="blockquote">
<p>“does that mean we need to set something up in the bash rc file / profile in the […] install instructions for pandoc?”</p>
</blockquote>
<p><strong>Turn 3 — Claude:</strong></p>
<p>Ran the one test that decides it — does <code>find_pandoc()</code> search <code>PATH</code> at all?</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th></th>
<th><code>find_pandoc()</code></th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>pandoc on <code>PATH</code>, no env var</td>
<td><strong>3.10</strong></td>
</tr>
<tr class="even">
<td><code>RSTUDIO_PANDOC</code> set, not on <code>PATH</code></td>
<td><strong>3.10</strong></td>
</tr>
<tr class="odd">
<td>neither</td>
<td><strong>0</strong></td>
</tr>
</tbody>
</table>
<p>Either mechanism suffices, so the <code>PATH</code> line was doing real work — and it is the only one of the two that also fixes <code>nbconvert</code>, which cannot read <code>RSTUDIO_PANDOC</code>. I then claimed the line was largely dead on macOS, because macOS has defaulted to zsh since 2019 and zsh never reads <code>~/.bash_profile</code>.</p>
<p><strong>Turn 4 — User:</strong></p>
<blockquote class="blockquote">
<p>“the UBC MDS guides on macOS have all students change their default shell to bash (from zsh) so all our students are on the same shell.”</p>
</blockquote>
<p><strong>Turn 4 — Claude:</strong></p>
<p>I had over-generalised from the developer machine I was executing on, which is on zsh. The cohort is deliberately standardised on bash, so <code>~/.bash_profile</code> <em>is</em> read and the design is coherent. What survived the correction — and became more important, not less — is that the whole macOS chain hangs on <code>chsh -s /bin/bash</code> succeeding, nothing verifies it, and it is a silent no-op if the student ever set Terminal’s “Shells open with: Command”. When it does not take, the symptom is <code>MISSING pandoc</code>, and the guide’s troubleshooting note then blames Quarto’s install location — the wrong diagnosis. Because every student is <em>meant</em> to take that step, an unverified <code>chsh</code> is a single point of failure for the whole cohort’s PDF export, not an edge case.</p>
<p><strong>Turn 5 — User:</strong></p>
<blockquote class="blockquote">
<p>“do you think i should just add a section to the install guide to go to the pandoc site and install it? macs have the .pkg file and windows have the msi files. i’m assuming this will be an easier install for students and also add it to the PATH?”</p>
</blockquote>
<p><strong>Turn 5 — Claude:</strong></p>
<p>The pandoc site is vague about macOS <code>PATH</code>, which is the part that mattered, so I inspected the <code>.pkg</code> payload without installing it. It lands at <strong><code>/usr/local/bin/pandoc</code></strong>, and <code>/usr/local/bin</code> is the <strong>first</strong> entry in <code>/etc/paths</code> — on <code>PATH</code> for every shell, bash or zsh, login or not, with no shell-config edit at all. The Windows <code>.msi</code> updates <code>PATH</code> itself.</p>
<p>And then the argument that actually settled it, which only became visible once the proposal was on the table: <strong>JupyterLab’s <code>File &gt; Save and Export Notebook As... &gt; PDF</code></strong> runs nbconvert inside the server process, inheriting the environment <code>jupyter lab</code> was launched from. If the <code>PATH</code> edit did not take, that route breaks — and no Makefile, wrapper, or symlink written in the project repo can reach it. A system-installed pandoc fixes every route at once.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>Add a pandoc install section to the install guides for all three operating systems, pointing at the official installers, and drop the borrowed-copy <code>PATH</code> line.</p>
<table class="caption-top table">
<colgroup>
<col style="width: 33%">
<col style="width: 33%">
<col style="width: 33%">
</colgroup>
<thead>
<tr class="header">
<th></th>
<th>before</th>
<th>after</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>supplier</td>
<td>Quarto’s bundle via a <code>~/.bash_profile</code> line</td>
<td>system <code>/usr/local/bin</code></td>
</tr>
<tr class="even">
<td>depends on <code>chsh</code> succeeding</td>
<td><strong>yes</strong></td>
<td>no</td>
</tr>
<tr class="odd">
<td>depends on a shell-config edit</td>
<td><strong>yes</strong></td>
<td>no</td>
</tr>
<tr class="even">
<td>works in JupyterLab’s PDF export</td>
<td>only if both of the above held</td>
<td>yes</td>
</tr>
</tbody>
</table>
<p>Details that came out of verifying it rather than assuming:</p>
<ul>
<li><strong>Removing the <code>PATH</code> line is safe, but check first.</strong> It pointed at Quarto’s <code>tools</code> directory, which also holds <code>dart-sass</code>, <code>deno</code>, <code>esbuild</code> and <code>typst</code>. Grepping the guides confirmed pandoc was the only consumer; the rest are Quarto-internal.</li>
<li><strong>Order the change.</strong> Add the install step, verify it, <em>then</em> remove the <code>PATH</code> line. One combined commit leaves anyone mid-install in a window where neither is in place.</li>
<li><strong>Students will now have four pandocs</strong> — system 3.10.2, Quarto’s bundle, RStudio’s 3.10, Positron’s 3.10. Harmless: Quarto uses its own regardless, and <code>rmarkdown</code>/<code>nbconvert</code> take the system one. But sample outputs in the guides showing <code>3.8.3</code> need updating.</li>
<li><strong>The version check needed care.</strong> Requiring exactly <code>3.10.</code> would create the same expiring-regex problem we had already flagged elsewhere, so I widened it to an alternation — which promptly matched the substring <code>8.3</code> inside <code>pandoc 3.8.3</code> and passed a version that should have failed. Anchoring the alternation to a leading space fixed it, and because that space is word-split away the success line still reads <code>OK        pandoc 3.10.2</code>.</li>
</ul>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>The forensics, and the discipline behind them. I reconstructed a two-supplier dependency chain, verified each link by execution rather than by recall — conda-forge metadata for <code>nbconvert-pandoc</code>, <code>find_pandoc()</code> return values under three different environments, Positron’s own source for <code>RSTUDIO_PANDOC</code>, the <code>.pkg</code> payload, <code>/etc/paths</code> ordering — and identified precisely which link had broken and when. I also caught several of my own bad tests along the way: a stale PDF that made a failing render look like a pass, a shell-quoting bug that turned a false negative into “evidence”, and a broken bare-<code>PATH</code> test that misattributed a missing <code>uv</code> to a missing pandoc. The diagnosis was right and it was earned.</p>
<p><strong>What required human expertise:</strong></p>
<p>Asking whether the arrangement needed to exist. Every fix I proposed — exporting <code>RSTUDIO_PANDOC</code> from an IDE bundle, installing a PyPI shim, symlinking into <code>.venv/bin</code> — accepted the existing strategy of <em>borrowing</em> a pandoc that some other tool had already installed, and tried only to make the borrowing more reliable. The user’s answer was upstream and simpler: install the dependency the way its authors distribute it.</p>
<p>It also took knowledge of how students actually use the tools. The decisive argument was JupyterLab’s GUI export menu — a route that exists in the product, is the one many students will reach for first, and is unreachable from anything in the project repo. I was optimising for <code>make</code>, because <code>make</code> was what was failing in front of me.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Two structural reasons.</p>
<p>The first is that I <strong>inherited a constraint from the guides without ever testing it</strong>. The existing docs were built around not installing a second copy of pandoc, and I absorbed that as a given. I characterised in great detail <em>why</em> the current arrangement was fragile without once asking whether the arrangement itself was necessary. Verifying a system thoroughly is not the same as questioning its premise, and being thorough inside the wrong frame is a very convincing way to stay there.</p>
<p>The second is a <strong>cheap-cleverness bias</strong>. I preferred solutions that added no install step, because an extra step reads as a cost — and a wrapper that fixes things “for free” feels elegant. For a cohort of around 100 students, one GUI installer that works identically on every route beats a shell-config edit that is downstream of an unverified step and invisible when it fails. The elegant fix was cheaper for me to write and more expensive for them to depend on.</p>
<p><strong>Key takeaway:</strong></p>
<p>When you find yourself making a workaround more robust, check whether the thing being worked around is required — the fix that removes a dependency chain usually beats the one that reinforces it.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>quarto</category>
  <category>pdf</category>
  <category>python</category>
  <category>over-engineering</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-install-dont-borrow-pandoc.html</guid>
  <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: A Makefile Is an Interface, Not Just a Build Graph</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-makefile-is-an-interface.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p><code>mds-setup-check</code> is the small UBC MDS project students clone in their first week to verify their software installation. It holds a Quarto document, a Jupyter notebook and an R Markdown document, and a <code>Makefile</code> that renders them to PDF and HTML through four different routes — LaTeX, Typst, pandoc, and a headless browser — because the routes do not all handle the same characters.</p>
<p>Three audiences touch that Makefile. A student, on day one, who has probably never run <code>make</code> before. An instructor or TA, debugging why a particular student’s machine cannot produce a PDF. And CI, which calls named targets. The repository’s own README links the three OS-specific MDS installation guides — macOS, Ubuntu and Windows — that send students here in the first place.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“restructure the Makefile around three targets — <code>all</code>, <code>install</code>, <code>clean</code>. <code>install</code> installs the packages, <code>all</code> renders everything to PDF and HTML, <code>clean</code> deletes the outputs.”</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude implemented exactly that. <code>install</code> ran <code>uv sync</code>, downloaded the Chromium that the WebPDF route needs, and called <code>renv::restore(prompt = FALSE)</code>. <code>all</code> depended on the four render routes. <code>clean</code> removed the PDFs, the HTML, the log files and the LaTeX intermediates. The dependency rules were real file rules — <code>check-quarto.html</code> depending on <code>check-quarto.qmd</code> — so <code>make</code> would skip work that was already done. Every non-obvious decision had a comment above it explaining why.</p>
<p>It was a correct build file, and Claude considered it finished.</p>
<p><code>all</code> was the first target in the file, which meant that typing <code>make</code> with no arguments started several minutes of rendering, including a browser download on a cold machine. Claude did not think of that as a decision at all. It was just where <code>all</code> happened to land.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p>Both corrections arrived in a single message. They are quoted here as the two separate requirements they contain, because they failed for different reasons.</p>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“in the makefile the first target should be commands, and it does a file grep for comments that can give a high level ‘help’ of the targets and what they do”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>The first target is not a build decision, it is the answer to the question “what happens when someone types <code>make</code> and presses enter.” For this repository, the person most likely to do that is a first-week student who is exploring, and the correct response to exploration is a list of what is available — not several minutes of work they did not ask for and cannot interpret.</p>
<p>The second half of the requirement was the part that made it maintainable: build the list by grepping the file rather than writing it by hand. Each target carries a <code>##</code> comment on its own line, and the recipe extracts them, so the help text is derived from the file and cannot drift away from what the file actually does.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“make sure that the grep command used to build the help list works on git bash”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>Claude had written the recipe without considering which tools it assumed. MDS supports macOS, Ubuntu and Windows, and Windows students work in Git Bash, whose MSYS userland is not the GNU userland the recipe would get on Ubuntu. Three specific things were ruled out:</p>
<ul>
<li><strong><code>grep -P</code></strong> — PCRE support is a compile-time option and is absent from a number of builds, so a pattern that needs it fails with <code>grep: support for the -P option is not compiled into this --disable-perl-regexp binary</code> rather than producing wrong output.</li>
<li><strong>Lazy quantifiers</strong> — <code>.*?</code> is not lazy in POSIX ERE. It parses, it matches greedily, and a target line containing a second colon silently produces a mangled help entry.</li>
<li><strong><code>sed -i</code></strong> — GNU takes <code>-i</code> with no argument, BSD requires a backup suffix. It is not needed here at all, but it is the reflex reach for text munging and it does not port.</li>
</ul>
<p>What is left is <code>grep -E</code>, <code>sort</code> and <code>awk</code>, all three of which are present in Git Bash on Windows as well as on macOS and Linux. Claude verified the recipe against the BSD tools that ship with macOS, which are the stricter of the two toolchains and the better proxy for MSYS.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p><code>commands</code> is the first target in the file, and its recipe reads the file it lives in:</p>
<pre class="make"><code>.PHONY: commands all install clean check pdf typst html webpdf

# `commands` is first, so a bare `make` prints this list rather than doing work.
# The list is built from the `##` comments on each target below, so it cannot go
# stale the way a hand-written help message does. grep -E, sort and awk are all
# present in Git Bash on Windows as well as on macOS and Linux.
commands:  ## Show this list of targets
    @grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
        | sort \
        | awk 'BEGIN {FS = ":.*## "}; {printf "  %-10s %s\n", $$1, $$2}'</code></pre>
<p>Each target then documents itself on its own line:</p>
<pre class="make"><code>install:  ## Install the Python and R packages, and the browser for webpdf
all: pdf typst html webpdf  ## Render every document by every route</code></pre>
<p>A bare <code>make</code> now prints:</p>
<pre><code>  all        Render every document by every route
  check      Check the rendered documents actually contain what they should
  clean      Delete everything the renders produced
  commands   Show this list of targets
  html       Render to HTML
  install    Install the Python and R packages, and the browser for webpdf
  pdf        Render to PDF through LaTeX
  typst      Render to PDF through Typst, which handles emoji and Greek
  webpdf     Render to PDF through a headless browser</code></pre>
<p>Properties worth naming:</p>
<ul>
<li><strong>It costs nothing and does nothing.</strong> A student who types <code>make</code> to see what happens gets an answer in milliseconds and has not started a download.</li>
<li><strong>It cannot go stale.</strong> Adding a target with a <code>##</code> comment adds it to the help. Adding one without a comment omits it, which is the correct default for an internal rule.</li>
<li><strong><code>sort</code> makes the order stable</strong>, so the list does not reshuffle when a target moves.</li>
<li><strong><code>$(MAKEFILE_LIST)</code></strong> rather than a hard-coded filename, so the recipe survives the file being renamed or included.</li>
</ul>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>The build graph itself. The three requested targets were implemented as asked, the file dependencies were real rather than <code>.PHONY</code> shortcuts, so incremental builds work, and the comments explained the non-obvious parts — particularly why the LaTeX PDF and the Typst PDF need distinct output names, since Quarto otherwise treats them as the same output and rendering one deletes the other. As a description of how to build the project, it was right.</p>
<p><strong>What required human expertise:</strong></p>
<p>Two things, and they are different in kind.</p>
<p>The first was recognising that <strong>the default target is an interface decision</strong>. Nothing about <code>all</code>, <code>install</code> and <code>clean</code> as a set implies which one runs bare, and Make’s answer — whichever is first — is an implementation detail that becomes the project’s front door. Knowing that the front door matters here required knowing who walks through it: a student in week one, for whom a multi-minute render with no explanation is indistinguishable from the program hanging.</p>
<p>The second was <strong>holding the whole supported machine population in view</strong>. “The grep works” was true on the machine Claude ran it on. The relevant question was whether it works on every machine the installation guides produce, and that population is defined by documents outside the Makefile.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Claude optimised for the stated deliverable. Three targets were named, three targets were built, and the acceptance criterion Claude applied to itself was “does this build the project correctly.” Ordering never came up as a question because, within that frame, ordering is arbitrary — Make does not care, and the graph is identical either way. The frame was too small: the file is not only a set of instructions for Make, it is also the first thing a human reads to find out what the project can do, and those two readers want different things from the same line of text.</p>
<p>The portability miss has the same shape but a sharper edge, because <strong>the requirement was knowable from the repository’s own contents</strong>. The README links three OS-specific install guides by name at the top of the file. Claude had read that README. It did not connect “this project is entered from a Windows guide” to “the recipe I am about to write must run under MSYS,” because the two facts lived in different tasks. Portability was treated as a property to check when someone raises it, rather than a constraint the repository had already declared.</p>
<p><strong>Key takeaway:</strong></p>
<p>When a Makefile has human readers, the first target is a user-interface decision, not a build decision — make a bare <code>make</code> explain itself rather than do work, and generate the explanation from the file so it cannot drift. And “works on my machine” is a weak standard whenever the machine population is already written down somewhere in the repository: read the install guides before choosing your shell tools, not after someone reports the failure.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>ux</category>
  <category>teaching</category>
  <category>maintainability</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-makefile-is-an-interface.html</guid>
  <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: When the Harness Lies, It Lies Plausibly</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-when-the-harness-lies.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p><code>mds-setup-check</code> is the repository UBC MDS students clone in their first week to confirm their machine can turn a source document into a PDF. It holds a Quarto document, a Jupyter notebook and an R Markdown document, and renders each of them through four routes — LaTeX, Typst, pandoc and a headless browser. Auditing it means running renders, extracting text back out of the results, and reading git history to find out how the toolchain used to behave.</p>
<p>This note is different from the others in this collection. There is no user correction in it. It is a pattern Claude caught in itself, four times in one session, while doing that audit — and a fifth time only after the bug had shipped.</p>
<p>The user was present throughout and every one of these was self-caught, which is exactly the point. Each was caught because a result happened to be checked against an expectation. Not one of them announced itself. Every single one would otherwise have become a confidently stated wrong conclusion, delivered with a command transcript underneath it as evidence.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<p>The question that produced two of the four:</p>
<blockquote class="blockquote">
<p>“how did the r markdown rendering work in the previous install instructions? pandoc was never really a hard requirement back then. why is it all of a sudden becoming a hard requirement now?”</p>
</blockquote>
<p>A good question with a checkable answer: render an R Markdown file under the conditions in question, and read the historical install guides to see what they told students to install.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>The method was right. Build a minimal fixture, render it by each route, extract the text back out of the output, and separately walk the git history of the install guide grepping for <code>pandoc</code>. Everything measured, nothing reasoned from documentation.</p>
<p>What Claude did not do at any point was ask whether the measuring apparatus worked. A command ran, output appeared, the output was read as a fact about the world. The gap between “the tool reported X” and “X is true” was never opened, because nothing forced it open — the harness never errored. It produced plausible output instead.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p>There are no user turns here. These are the four, in the order they happened.</p>
<p><strong>Failure 1 — a stale artifact read as a fresh pass.</strong></p>
<p>Testing whether R Markdown could render LaTeX math, Claude ran the render, then extracted text from <code>m.pdf</code> and reported that the math had typeset correctly.</p>
<p>The render had failed:</p>
<pre><code>pandoc version 1.12.3 or higher is required and was not found</code></pre>
<p><code>m.pdf</code> was left over from an earlier, successful run through a <em>different</em> engine. The file existed, it was a real PDF, it contained real typeset math, and it had nothing to do with the command that had just run. A stale PDF and a fresh PDF are the same file to anyone reading its text.</p>
<p><em>The fix:</em> delete the output before every route, never after. A cleanup step at the end protects the next run only if the current run reaches the end.</p>
<p><strong>Failure 2 — the harness corrupting the input under test.</strong></p>
<p>The fixture for that same test was generated with <code>echo "$BODY" &gt; m.Rmd</code>. In zsh the <code>echo</code> builtin interprets backslash escapes, so <code>$\alpha$</code> in the body became <code>$lpha$</code> with an embedded bell character — the <code>\a</code> had been eaten and replaced with U+0007.</p>
<p>LaTeX then failed with:</p>
<pre><code>! Text line contains an invalid character.</code></pre>
<p>Which reads exactly like a finding. The document under test contains a character LaTeX cannot handle — that is the <em>shape</em> of the bug the audit was looking for. It was a defect in the fixture generator, being reported as a defect in the toolchain.</p>
<p><em>The fix:</em> write fixtures with a quoted heredoc, never <code>echo</code>. And after writing a test document, grep it for the content it is supposed to contain.</p>
<p><strong>Failure 3 — a shell-quoting bug silently emptying every query.</strong></p>
<p>To find out whether the historical install guides had ever mentioned pandoc, Claude walked the commits with:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb3-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> show <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$PREV</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">:content/resources_pages/installation_instructions.md"</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">grep</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-ci</span> pandoc</span></code></pre></div></div>
<p>zsh reads <code>:c</code> as a history-style modifier on the parameter, so <code>$PREV:c</code> was consumed as <code>$PREV</code> plus a modifier, and the argument that reached git was <code>4b81f0eontent/resources_pages/installation_instructions.md</code>. Every revision returned <code>0</code>.</p>
<p>Claude’s first reading of four consecutive zeroes was “the guides never mentioned pandoc” — which happened to be the answer the question was fishing for. <code>git show</code> had in fact errored on every call, but its complaint went to stderr while the pipeline dutifully printed a number, and a number is what was being read.</p>
<p><em>The fix:</em> <code>"${PREV}:path"</code>, and treat a uniformly negative result as suspicious. Four zeroes is either a finding or a broken command, and the broken command is more likely.</p>
<p><strong>Failure 4 — an <code>&amp;&amp;</code> chain half-succeeding.</strong></p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb4-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> checkout main <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">&amp;&amp;</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> pull</span>
<span id="cb4-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> checkout <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-b</span> feat/pandoc-version-check</span></code></pre></div></div>
<p>The checkout failed on uncommitted changes, so the pull never ran — the <code>&amp;&amp;</code> did its job. But the <em>next line</em> ran anyway, and branched from a stale <code>main</code> carrying an unrelated uncommitted edit. <code>&amp;&amp;</code> guards the command after it, not the rest of the script.</p>
<p>Caught before pushing, and the recovery was ordinary: save the diff as a patch, commit the unrelated change where it actually belonged, re-branch from an updated <code>main</code>, re-apply. The failure mode is what matters. A half-run chain still leaves you on a branch. It is just not the branch you think you are on.</p>
<p><strong>A fifth, different in kind — a live regression, caught only after it shipped.</strong></p>
<p>The other four wasted Claude’s own time. This one reached a student-facing script. A version check for pandoc was added to <code>check-setup-mds.sh</code> as:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb5" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb5-1"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">pandoc</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"(^| )(3\.(1[0-9]|[2-9][0-9])|[4-9]\.[0-9]+|...)"</span></span></code></pre></div></div>
<p>The pattern contains a literal space, in the <code>(^| )</code> that anchors the version to a word boundary — and the array holding it was expanded unquoted, <code>for sys_prog in ${sys_progs[@]}</code>. The entry split in half. The check ran against <code>pandoc=(^|</code> and the live log said so:</p>
<pre><code>MISSING   pandoc=(^|</code></pre>
<p>for a machine whose pandoc status was never actually determined. The fix was <code>(^|[[:space:]])</code>, which matches identically and contains no literal space, plus <code>"${sys_progs[@]}"</code> so no future entry can split the same way.</p>
<p>Worth recording alongside it: quoting the loop does <strong>not</strong> fix the related globbing problem. <code>sys_progs=(R=4.* ...)</code> expands its globs at assignment time, so a file named <code>R=4.txt</code> in the working directory still rewrites that entry no matter how carefully the array is expanded later.</p>
<p><strong>What the five have in common.</strong></p>
<p>In every case the harness failed in a way that produced plausible output rather than an error. A stale PDF reads exactly like a fresh one. A zero grep count reads exactly like a real absence. A half-run <code>&amp;&amp;</code> chain leaves you on a branch. A split array entry produces a check result, in the right column, in the right format. None of these raise. They all return something a reasonable person would read as an answer.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>Four practices, each drawn from what actually caught one of the above:</p>
<ul>
<li><strong>Delete the output before the test, not after.</strong> Any check for a file’s existence or contents is invalid if a previous run could have left one behind. <code>make clean</code> removes every rendered PDF, HTML file and LaTeX intermediate, and it is run <em>before</em> a route, not as tidying afterwards.</li>
<li><strong>A uniformly negative result deserves the same scrutiny as a positive one.</strong> Zero hits across every revision is a claim about the world <em>or</em> a broken command, and the second hypothesis is cheaper to test.</li>
<li><strong>Assert that the harness can fail before trusting what it reports.</strong> <code>ci/assert-renders.py</code> and <code>ci/assert-contract.py</code> were both checked against deliberate breakage — removing a dependency from <code>pyproject.toml</code>, deleting a word from a rendered file — to confirm they could go red at all. <code>assert-renders.py</code> also carries negative expectations: routes known not to support a character assert that it is <em>absent</em>, so a future fix shows up as a failing test rather than being absorbed unnoticed.</li>
<li><strong>Verify the fixture is what you think it is.</strong> After writing a test document, grep it for the content it is supposed to contain, before drawing any conclusion from how it renders.</li>
</ul>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>It caught all four itself, before any of them reached a conclusion the user acted on, and it caught the fifth from a single odd-looking line in a real log. That is worth stating plainly rather than as false modesty — the audit was run by executing things and reading what came back, which is the only reason there was anything to notice. Reasoning about the toolchain from documentation would have produced none of these errors and none of the findings either.</p>
<p><strong>What required human expertise:</strong></p>
<p>Almost nothing, and that is the unusual part of this note. What it required instead is a habit that is normally supplied by an experienced engineer: the reflex to distrust an answer that arrives too neatly. The user’s question — “pandoc was never really a hard requirement back then” — carried a hypothesis, and the broken grep returned exactly the evidence that hypothesis predicted. An experienced reviewer feels that as a warning. Claude felt it as confirmation.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>The harness is invisible infrastructure. Claude’s attention was on the <em>subject</em> of each test — does LaTeX render math, did the guides mention pandoc — and the commands were treated as a transparent window onto that subject rather than as code that can be wrong. Every one of these five is a bug in code Claude wrote; none of them is in the system under test.</p>
<p>That inversion is structural rather than accidental. The task is to produce an answer, and plausible output satisfies that goal completely. Nothing in a stale PDF or a zero grep count signals “stop” — they are indistinguishable from success, which means the normal error-driven correction loop never fires. It fires on exceptions, and none of these threw one. Add that shell semantics differ in ways that are silent by design — zsh’s <code>echo</code> interpreting escapes, zsh’s <code>:c</code> modifier, word splitting on unquoted arrays — and the result is a class of failure that is both easy to write and structurally hard to notice.</p>
<p>The honest summary is that these were caught by luck of noticing an oddity, not by a systematic guard. The distance between “I noticed that number looked wrong” and “this harness cannot silently pass” is the whole lesson.</p>
<p><strong>Key takeaway:</strong></p>
<p>Before you believe what a test tells you, prove the test can fail — delete the output first, break something on purpose to watch it go red, and treat a clean negative result as a bug in your command until you have shown otherwise.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>testing</category>
  <category>debugging</category>
  <category>pdf</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-when-the-harness-lies.html</guid>
  <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Knowing Which Characters to Test</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-which-characters-to-test.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A setup-check repository for the UBC Master of Data Science program. Incoming students clone it and run <code>make</code> to confirm their machine can do the one thing the whole program depends on: turn a source document into a PDF they can hand in. Three fixtures stand in for the three formats the program uses — <code>check-quarto.qmd</code>, <code>check-notebook.ipynb</code> and <code>check-rmarkdown.Rmd</code> — and a Makefile renders each of them through every available route.</p>
<p>Each fixture ends with a section headed “Characters that are not plain English”, containing a single line:</p>
<pre><code>Montréal · naïve · Öl · 5 °C · α β γ · 10 – 20</code></pre>
<p>Claude had been auditing that render pipeline, and found a real bug in it. Rendering through LaTeX exits 0, produces a PDF, and prints nothing alarming — but extracting the text back out of that PDF shows the literal Greek letters <code>α β γ</code> have become U+FFFD replacement characters. Every LaTeX route, silently, on a green build. Finding it took running the renders and reading the output files, not reading the source.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<p>Verify that the setup check actually verifies something — that a student who gets a PDF has a working toolchain, and a student with a broken one gets told.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude reported the U+FFFD finding as a Unicode bug in the fixture, and proposed the fixes that framing implies: a font with Greek coverage, <code>\setmainfont</code>, switching the engine, adding a fallback font for the missing glyphs. Make the existing line render, and the check is honest again.</p>
<p>That analysis was correct as far as it went. The line did not render; the proposed fixes addressed the line. What Claude never asked was whether that line was the right line.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>latex math formulas and equations matter for MDS</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>A grep for math delimiters across all three fixtures returned nothing — one <code>$</code> in the entire set, and it was <code>R.version$version.string</code> in an R chunk. Not a single dollar-sign pair, <code>\(</code>, <code>$$</code> or equation environment anywhere. A statistics program’s assignments are substantially made of equations, and the document that certifies “your machine can render your assignments” contained none. The audit had been thorough about a test surface that omitted the single most common thing students write.</p>
<p>Adding math and re-rendering showed it works — including through LaTeX, which is exactly what LaTeX is for.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>we should make sure the ipynb, rmd, qmd files contain emojis since sometimes we use those in hw assignments</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>Also entirely untested, and also broken. Emoji added to the fixtures came back out of every LaTeX-rendered PDF as replacement characters — between 6 and 9 U+FFFD per document, depending on how many the fixture carried. Same silent failure mode as the Greek letters, same exit code 0.</p>
<p>Nothing in the repository would have suggested emoji were worth testing. That they appear in real handouts is a fact about how this program writes assignments, and it lives with the people who write them.</p>
<p><strong>Turn 3 — User:</strong></p>
<blockquote class="blockquote">
<p>we should also test render to html and pdf</p>
</blockquote>
<p><strong>Turn 3 — Claude:</strong></p>
<p>This one changed the diagnosis rather than adding to it. Rendering all three fixtures to HTML produced zero replacement characters — accents, math, literal Greek and emoji all intact, in all three formats. So this was never a Unicode problem, or a pandoc problem, or a fixture problem. It was specific to LaTeX, and only ever to LaTeX. Testing one output format had made a route-specific failure look like a document-wide one.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>Fixtures that carry accents, math, literal Greek and emoji; the Makefile rendering each one by every route; and <code>ci/assert-renders.py</code>, which extracts text back out of the rendered files and asserts per route on what should be in it. The measured matrix:</p>
<table class="caption-top table">
<thead>
<tr class="header">
<th>route</th>
<th>accents</th>
<th>math</th>
<th>literal Greek</th>
<th>emoji</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>PDF via LaTeX</td>
<td>yes</td>
<td>yes</td>
<td><strong>no</strong></td>
<td><strong>no</strong></td>
</tr>
<tr class="even">
<td>PDF via Typst</td>
<td>yes</td>
<td>yes</td>
<td>yes</td>
<td>yes</td>
</tr>
<tr class="odd">
<td>HTML</td>
<td>yes</td>
<td>yes</td>
<td>yes</td>
<td>yes</td>
</tr>
<tr class="even">
<td>WebPDF (Chromium)</td>
<td>yes</td>
<td>yes</td>
<td>yes</td>
<td>yes</td>
</tr>
</tbody>
</table>
<p>The sharp edge is in the second and third columns. Math-mode <code>$\alpha$</code> typesets correctly in a LaTeX PDF; the literal character <code>α</code> in prose does not. They look like the same capability and are not. An assignment full of equations is safe on the default route. An assignment with a 🎉 in a section heading, or a <code>σ</code> typed directly into a sentence, is silently corrupted on that same route — and the student sees a PDF and a zero exit code and has no reason to look.</p>
<p>Because the differences are a measured property of the toolchain rather than a bug in any one document, the checker encodes them as expectations per route, including negative ones: routes known not to support a character assert that it is <em>absent</em>, so that a future TeX upgrade quietly fixing the problem shows up as a failing test rather than being absorbed unnoticed.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>The investigation itself. Nobody had noticed the corruption before, because every signal a person normally checks — exit status, file exists, no error output — was green. Getting to it required rendering the documents, extracting the text back out of the PDFs, and comparing against the source, and then doing the same across four routes and three formats to turn “it’s broken” into a matrix. Every claim in the table above was produced by running something. None of it was reasoned from documentation.</p>
<p><strong>What required human expertise:</strong></p>
<p>The list of what an assignment actually contains. That is not in the repository, and it cannot be derived from it. It comes from having taught the course, written the handouts and read what students submit. Three short sentences from the user added two whole categories of input — math and emoji — one of which was entirely fine and one of which was entirely broken, and a third instruction that reclassified the bug from “Unicode” to “LaTeX”.</p>
<p>Note how cheap those interventions were. None was longer than a line. They were not corrections of Claude’s reasoning; they were facts Claude had no access to.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>The fixture defined the test surface, and Claude accepted that definition. There was a section headed “Characters that are not plain English”, so the question became “do these characters render?” — and that question has an answer, which is findable, which felt like the work. The prior question, “is this the right set of characters?”, never got asked, because answering it requires information that is not in the codebase and Claude had no signal that it was missing.</p>
<p>This is a specific form of confirmation bias, and it is structural rather than accidental. Given an existing test suite, the available work is verifying that the tests pass. Asking whether the tests cover the real input distribution requires knowing the real input distribution — so the question is invisible from inside the repository, and the audit terminates in a state that looks complete. Claude reported a bug found, a fix proposed, and evidence gathered. All true, and the thing being tested was still the wrong thing.</p>
<p><strong>Key takeaway:</strong></p>
<p>When an AI audits a test suite, it will verify that the existing tests pass rather than ask whether they cover the real inputs — the first question is answerable from the code and the second is not. The set of inputs your users actually produce is domain knowledge. It has to be supplied, it is usually one sentence long, and it is often the difference between a test suite that passes and a test suite that means something.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>testing</category>
  <category>pdf</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-which-characters-to-test.html</guid>
  <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: The Wrong Engine, Not the Wrong Font</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-wrong-engine-not-wrong-font.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A document-rendering toolchain for UBC MDS. The project carries three fixture documents — a Quarto <code>.qmd</code>, a Jupyter notebook, and an R Markdown <code>.Rmd</code> — and a Makefile that renders each one. The point of the fixtures is diagnostic: a student runs <code>make</code>, and if the documents come out, their toolchain works.</p>
<p>Each fixture contains a line of characters that assignments actually use:</p>
<pre><code>Montréal · naïve · Öl · 5 °C · α β γ · 10 – 20 · "curly quotes"</code></pre>
<p>plus a few emoji in the prose.</p>
<p>Every PDF route went through LaTeX, and every one of them was quietly broken. Quarto to <code>lualatex</code>, <code>nbconvert</code> to <code>xelatex</code>, and <code>rmarkdown</code> to <code>xelatex</code> all <strong>exited 0</strong>, produced a PDF, and replaced the emoji and the literal Greek letters with U+FFFD — the replacement character. Nine of them per document, in all three routes. Nothing in the exit code, the logs, or the file listing said so. You had to open the PDF, or extract its text, to find out.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<p>The report was a bug report with an implied diagnosis already inside it: <em>the PDFs render, but the emoji and Greek letters come out as replacement characters — fix it.</em></p>
<p>The framing is accurate as far as it goes. It is also a framing, and the whole lesson here is about what it quietly rules out.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>I took it as a LaTeX font problem and went to work inside that assumption. The work itself was reasonable, and some of it was genuinely hard-won:</p>
<ul>
<li>Traced how Quarto, <code>nbconvert</code> and <code>rmarkdown</code> each configure <code>fontspec</code>, and confirmed the routes do not even agree on an engine — Quarto’s PDF format uses <code>lualatex</code>, the other two use <code>xelatex</code>.</li>
<li>Tested <code>\setmainfont</code> variants and found a real portability trap: a <em>font family name</em> (<code>\setmainfont{Latin Modern Math}</code>) resolves under <code>lualatex</code> and <strong>hard-fails</strong> under <code>xelatex</code>, which wants a file name unless you hand it <code>Path=</code> and an extension. A fix developed against the Quarto route would have broken the other two.</li>
<li>Found that when a Greek letter has no text glyph, LaTeX silently falls back to <strong>math mode</strong> — and math mode does not typeset the character you wrote. It emits U+1D6FC MATHEMATICAL ITALIC SMALL ALPHA in place of U+03B1 GREEK SMALL LETTER ALPHA. The PDF <em>looks</em> right and greps wrong. That is a subtle finding and it later mattered.</li>
<li>Installed three TeX packages to test fixes: <code>newcomputermodern</code>, <code>emoji</code>, and <code>noto-emoji</code>.</li>
</ul>
<p>That produced a real result. <code>newcomputermodern</code> genuinely fixes literal Greek under LaTeX.</p>
<p>It never fixed the emoji. The <code>emoji</code> package works through macro syntax — <code>\emoji{check-mark-button}</code> — so an emoji <em>typed literally into markdown</em>, which is the only way a student is ever going to write one, still came out as U+FFFD. I had spent a lot of effort in a solution space that could not fully solve the problem, and I was still in it.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>we should also test render to html and pdf. i’m assuming all the characters symbols and emojis render for the html?</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Correct, and it did two separate things at once.</p>
<p>Adding HTML targets for all three fixtures and extracting the text gave the first measurement I had that was not a failure: <strong>0 replacement characters</strong> in all three HTML outputs, against <strong>9</strong> in every LaTeX route. Same source files, same characters, same build.</p>
<p>That immediately localised the fault. This was not “Unicode is hard” or “the documents are wrong” — it was <strong>specific to LaTeX</strong>, and no amount of font surgery on the source was going to be the shape of the answer.</p>
<p>It also named what I had been missing methodologically. I had three routes, all broken, and I had been comparing them <em>to each other</em>. That tells you which route fails differently, not what a working one looks like. A known-good route is a control, and I had one available the entire time.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>we should also do the test export using pdf/latex and also pdf/typst</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>Typst is Quarto’s other PDF engine. It ships <strong>with Quarto</strong>, needs no LaTeX at all, and is one word in the format block.</p>
<p><code>quarto render check-quarto.qmd --to typst</code>: <strong>0 replacement characters.</strong> Emoji, literal Greek, accents, dashes, curly quotes and the rendered math, all present. The problem I had been partially solving with three TeX packages did not exist on that engine.</p>
<p>One real wrinkle turned up in wiring it in: Quarto treats the LaTeX PDF and the Typst PDF as the same output for a given input, so rendering one deleted the other. Both formats need an explicit <code>output-file:</code> to be named apart.</p>
<p>And there was an uncomfortable second finding. WebPDF — <code>nbconvert</code> through headless Chromium — also renders everything perfectly, and <strong>I had already run it and seen that earlier in the same investigation</strong>. I did not draw the conclusion, because the project’s own documentation describes WebPDF as the “escape hatch when LaTeX objects to something.” I had read that line and absorbed its framing: a fallback, not a better route. So a working answer sat in my own scroll-back, correctly labelled as working, and I walked past it.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>The Makefile now has four routes rather than one, each documented by what it can actually carry:</p>
<pre><code>#   pdf     LaTeX      accents and math yes; literal Greek and emoji NO
#   typst   Typst      everything, and no LaTeX involved
#   html    pandoc     everything
#   webpdf  Chromium   everything, needs the browser `make install` downloads</code></pre>
<p>And because “it rendered” was exactly the signal that had been lying, <code>make check</code> runs <code>ci/assert-renders.py</code>, which opens the output files and asserts on the extracted text. Each route is tagged <code>LATEX</code> or <code>FULL</code>, and each character check declares which kinds support it:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb3-1">CHECKS <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> [</span>
<span id="cb3-2">    (<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"accented latin"</span>, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Montréal"</span>, {LATEX, FULL}),</span>
<span id="cb3-3">    (<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"degree sign"</span>,    <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"°C"</span>,       {LATEX, FULL}),</span>
<span id="cb3-4">    (<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"en dash"</span>,        <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"–"</span>,        {LATEX, FULL}),</span>
<span id="cb3-5">    (<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"literal Greek"</span>,  <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"α"</span>,        {FULL}),</span>
<span id="cb3-6">]</span></code></pre></div></div>
<p>Two properties of that design are the point:</p>
<ul>
<li>The LaTeX limitation is <strong>encoded as an expectation</strong>, not hidden. A <code>FULL</code> route missing <code>α</code> fails; a <code>LATEX</code> route that suddenly <em>gains</em> it also fails, with the message <code>this route was not expected to support it; update ci/assert-renders.py</code>. The known defect cannot silently change in either direction.</li>
<li><code>FULL</code> routes additionally fail on any U+FFFD anywhere in the text, which catches dropped glyphs nobody thought to write a check for.</li>
</ul>
<p>The U+1D6FC discovery from the failed font work earns its keep here: the assertion greps for literal <code>α</code>, so a route that renders a <em>mathematical italic alpha</em> instead is correctly counted as not supporting the character, rather than passing on a lookalike.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>The investigation was real and it was reproducible. The <code>lualatex</code>/<code>xelatex</code> divergence on <code>\setmainfont</code> is a genuine portability trap that would have bitten a fix developed against one route. The math-mode substitution is subtle, easy to miss, and directly shaped the final assertion script. <code>newcomputermodern</code> fixing Greek under LaTeX is a true finding. None of the work was wrong. It was competent work in a space that could not close.</p>
<p><strong>What required human expertise:</strong></p>
<p>Two interventions, and they are different in kind.</p>
<p>The first was <strong>methodological</strong>: get a control. Three failing routes compared to each other produce a taxonomy of failures; one working route localises the fault in a single measurement. 0 versus 9 said “this is a LaTeX property” faster and more conclusively than any amount of font archaeology.</p>
<p>The second was <strong>knowing the problem space has more than one engine in it</strong>. Not deeper LaTeX knowledge — the opposite. The knowledge that the LaTeX question was optional.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>I accepted the framing and then optimised inside it. “The LaTeX route is broken, fix LaTeX” is a well-formed problem with a rich, absorbing solution space — fonts, packages, engines, fallback rules — and every hour inside it produced <em>something</em>, which is exactly what makes it hard to leave. The better question, “is there a different engine?”, is not deeper in the space. It requires stepping outside the problem as posed, and nothing inside the space prompts you to.</p>
<p>Two aggravating details make this worse rather than better. Quarto <strong>ships</strong> Typst — the alternative was already installed, and I had listed Quarto’s tools directory and seen the <code>typst</code> binary sitting in it. And WebPDF had already rendered every character correctly in front of me; I discounted it because the docs called it an escape hatch, letting a <em>label</em> about when to reach for something override a <em>measurement</em> of what it did.</p>
<p>The general failure is that I treated the boundaries of the stated problem as the boundaries of the search. A human with less LaTeX knowledge than me solved it in one sentence, because they were not standing inside LaTeX.</p>
<p><strong>Key takeaway:</strong></p>
<p>Before optimising inside a broken component, ask two questions the framing will not ask for you — <em>is there a known-good path to compare against?</em> and <em>is there another component entirely?</em> — because a control localises the fault in one measurement, and the alternative engine is often already installed.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>quarto</category>
  <category>pdf</category>
  <category>debugging</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-17-wrong-engine-not-wrong-font.html</guid>
  <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Consent Is Not the Same as Redaction</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-15-consent-not-redaction.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>Reviewing <code>check-setup-mds.sh</code>, the script UBC MDS students run to verify their software installation. The script writes <code>check-setup-mds.log</code>, and <strong>students are instructed to submit that log to their instructors</strong> as proof their setup works.</p>
<p>While auditing the script, a check turned up this near the end:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb1-1"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-e</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">${ORANGE}</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">## Environmental variables</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">${NC}</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;&gt;</span> check-setup-mds.log</span>
<span id="cb1-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">env</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;&gt;</span> check-setup-mds.log</span></code></pre></div></div>
<p>The full process environment — every API key and access token the student happens to have exported — was being written into the file they were about to send to their instructors. Running the script on a developer machine confirmed it: variables shaped like <code>GITHUB_TOKEN=ghp_AAAABBBBCCCCDDDD1111</code> and <code>SERVICE_TOKEN=perm-QWERTY.1234.abcd</code> landed in the log verbatim.</p>
<p>Two further details made it worse. The section was appended to the file <em>after</em> the script had already printed its output to the screen, so <strong>the student never saw it</strong>. And the script’s closing line asked them to “review that there is no SENSITIVE INFORMATION such as passwords or access tokens” — auditing a section they had no way of knowing existed.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“we might want to remove (or at least comment out or give the user ability to say no — default no, optional yes — to dump out env variables. those will hold secrets that i’ve seen students dump into their homework assignments without them realizing what is being reported”</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>I treated it as a data-minimisation problem and built a solution with three parts:</p>
<ol type="1">
<li><strong>An allowlist.</strong> Instead of dumping everything, write ~20 named variables that are diagnostically useful and cannot hold a credential: <code>PATH</code>, <code>SHELL</code>, <code>R_LIBS_USER</code>, <code>PYTHONPATH</code>, <code>CONDA_PREFIX</code>, and so on.</li>
<li><strong>An opt-in flag</strong> for the full dump: <code>MDS_INCLUDE_ENV=1</code>, chosen for consistency with an existing <code>MDS_BASE_URL</code> override already in the script.</li>
<li><strong>Redaction</strong> of the shell-config sections, masking any value whose variable name looked like a credential.</li>
</ol>
<p>I tested it with a planted canary and reported that the default path leaked nothing. It was a genuine improvement — the actual secrets no longer appeared — and I thought it was done.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“i don’t want environment variables in the log file without an explicit YES from the user. not showing it to the user, but still putting it into the log file where we have them send us the log file as part of installation check is misleading and goes against security and trust”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>The allowlist was still environment data going into a file the student sends, without the student ever seeing it on screen. “Fewer secrets” is not the same as “nothing they didn’t consent to.” I removed the allowlist entirely — zero environment variables in the log by default — and added an on-screen notice when the opt-in was used, so the disclosure was not buried in a file about to be transmitted.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“i want you to prompt the user yes/no where the default is NO. so it’s part of running the script. new student’s won’t know how to set environment variables, and we don’t want to set the value to 1 for them during the install process. that defeats the purpose”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>The flag was wrong for this user population. A TA telling a student “run it with <code>MDS_INCLUDE_ENV=1</code>” produces exactly the outcome the change was meant to prevent — the student sets it without understanding what it does. Replaced with a runtime prompt.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb2-1"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">include_env</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'no'</span></span>
<span id="cb2-2"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">[</span> <span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">-t</span> 0 <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">]</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">;</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">then</span></span>
<span id="cb2-3">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span></span>
<span id="cb2-4">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'Your environment variables can help diagnose PATH problems, but they often hold'</span></span>
<span id="cb2-5">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'API keys and access tokens, and you are about to share this log with instructors.'</span></span>
<span id="cb2-6">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">read</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-r</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-p</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'Include environment variables in the log? [y/N] '</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">include_env_reply</span></span>
<span id="cb2-7">    <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">include_env_reply</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$(</span><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">printf</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'%s'</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$include_env_reply</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">tr</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'[:upper:]'</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'[:lower:]'</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">)</span></span>
<span id="cb2-8">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">case</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$include_env_reply</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> <span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">y</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span> <span class="ss" style="color: #20794D;
background-color: null;
font-style: inherit;">yes</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">)</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">include_env</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'yes'</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">;;</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">esac</span></span>
<span id="cb2-9"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">fi</span></span></code></pre></div></div>
<p>Design properties, each verified by execution rather than assumed:</p>
<ul>
<li><strong>Default no.</strong> Enter, <code>n</code>, and any unrecognised input (<code>sure</code>, <code>ok</code>, <code>1</code>, <code>true</code>) all leave it out. Only <code>y</code>/<code>yes</code> in any capitalisation opt in.</li>
<li><strong>Asked at runtime</strong>, not exposed as a variable — the consent happens in the student’s world, not the developer’s.</li>
<li><strong><code>[ -t 0 ]</code> guard.</strong> With nothing attached to stdin the prompt is skipped entirely and the answer stays no, so scripted and CI runs default to safe instead of hanging.</li>
<li>Shell-config sections are still recorded, because a leftover <code>PATH</code> edit or <code>conda init</code> block is a common cause of real failures — but credential-shaped values are masked.</li>
</ul>
<p>One bug surfaced only through testing: the redaction pattern matched <code>PAT</code> inside <code>PATH=</code> and masked the single most diagnostically important variable in the file. A word boundary fixed it. Another surfaced because a first test harness piped input into the script, which meant <code>[ -t 0 ]</code> was false and every case silently took the default branch — the tests “passed” while exercising nothing.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>Identifying the vulnerability at all — it took actually running the script and inspecting the output file, not reading it. The threat model was correct: environment dumps carry credentials, and this log is transmitted. Redaction, allowlisting and canary-based testing are all sound techniques, and the shell-config masking survived into the final version.</p>
<p><strong>What required human expertise:</strong></p>
<p>Two things, and they are different in kind.</p>
<p>The first was a <strong>principle</strong>: in a system where a file is transmitted, anything placed in that file without the user seeing it is a trust violation, independent of whether it contains a secret. I was solving “reduce the exposure.” The actual problem was “obtain informed consent.” Those produce different designs, and only the second one survives a student asking “what did I just send you?”</p>
<p>The second was <strong>knowledge of the user population</strong>. Setting an environment variable is a trivial ask for a developer and a genuine barrier for a first-week student — and, crucially, the workaround for that barrier is a TA saying “just run it with the flag,” which reproduces the original harm at scale. The mechanism has to live where the user already is.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Both misses came from optimising against the wrong frame. I read the request as a security task and reached for security tooling — minimise, redact, allowlist. The word “explicit YES” was in the original prompt and I mapped it onto “an explicit action by the user,” which a flag technically satisfies, rather than “informed agreement at the moment of collection.”</p>
<p>The flag choice compounded it. I justified <code>MDS_INCLUDE_ENV=1</code> by consistency with an existing override in the same file — a real and normally good instinct — but that override exists for maintainers doing preview deploys, not for students. <strong>I matched the convention of the codebase instead of the capability of its users</strong>, and nothing in the code itself would have told me the difference. The script does not know who runs it; only the person who teaches the course does.</p>
<p><strong>Key takeaway:</strong></p>
<p>Reducing what gets collected is a security fix; asking permission is a trust fix — and when the artifact gets shared with someone who has power over the user, only the second one is sufficient. Ask where the user already is, default to no, and make unrecognised input mean no.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>security</category>
  <category>shell</category>
  <category>teaching</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-08-15-consent-not-redaction.html</guid>
  <pubDate>Sat, 15 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: rm -rf Cleanup of State It Did Not Create</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-31-rm-rf-cleanup-of-state-it-did-not-create.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A long session on a static-site project (<code>meanwhile</code>), building five CSV codecs across several subagent tasks. The subagents write their reports into a git-ignored scratch directory at the repo root, <code>.superpowers/sdd/</code>. At the end of the session the user asked for a release.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“push, release, clean up, do it all”</p>
</blockquote>
<p>“Clean up” meant: remove the temporary git worktree an agent had used, and leave the tree tidy before tagging.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>While tidying, Claude wanted to know whether <code>.superpowers/</code> was covered by <code>.gitignore</code>. It had <strong>already</strong> asked that question safely:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode sh code-with-copy"><code class="sourceCode bash"><span id="cb1-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> check-ignore <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-v</span> .superpowers/   <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># → "NOT IGNORED"</span></span></code></pre></div></div>
<p>That answer looked wrong (the directory had shown as ignored in an earlier <code>git status --ignored</code>), so Claude wrote a second, <em>filesystem-mutating</em> probe to settle it:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode sh code-with-copy"><code class="sourceCode bash"><span id="cb2-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">ls</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-d</span> .superpowers <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">&amp;&amp;</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">ls</span> .superpowers/<span class="pp" style="color: #AD0000;
background-color: null;
font-style: inherit;">*</span>/          <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># ← printed: 3 items already there</span></span>
<span id="cb2-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">mkdir</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-p</span> .superpowers/sdd <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">&amp;&amp;</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">touch</span> .superpowers/sdd/probe.md</span>
<span id="cb2-3"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> status <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--porcelain</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">|</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">grep</span> superpowers <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">||</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"(ignored or invisible)"</span></span>
<span id="cb2-4"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">rm</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-rf</span> .superpowers                               <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># ← intended: remove the probe file</span></span></code></pre></div></div>
<p>The last line was written as teardown for state Claude assumed it had created. It had not. The directory already existed and held three agent reports. They were git-ignored, so they were never committed, and they are unrecoverable.</p>
<p>Two lines earlier, Claude’s own command had <strong>printed the contents it was about to delete</strong>.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — Claude</strong> (unprompted, in the next message):</p>
<blockquote class="blockquote">
<p>“I just deleted <code>.superpowers/</code> while probing whether it was ignored — it was, and it held three agent reports that commit messages reference. They were never committed, so they’re gone.”</p>
</blockquote>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“why did you delete .superpowers? what prompted you to do that without asking me? or why did you do that on your own?”</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong></p>
<p>Gave the account above, checked recoverability (scratchpad copies, <code>git fsck</code>) and confirmed the reports were gone; the mutation-harness scripts they referenced survived elsewhere, and the substance of the reports had been duplicated into commit messages.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>No fix — the files are gone. What changed is the rule, stated by the user’s question rather than by Claude’s report of the symptom:</p>
<p><strong>Anything Claude did not create is not Claude’s to delete without asking.</strong></p>
<p>The safe form of the probe Claude wanted:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode sh code-with-copy"><code class="sourceCode bash"><span id="cb3-1"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Non-mutating; answers the same question:</span></span>
<span id="cb3-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">git</span> check-ignore <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-v</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">--</span> .superpowers/sdd/anything.md</span>
<span id="cb3-3"></span>
<span id="cb3-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># If a filesystem probe is genuinely required, scope the teardown to the</span></span>
<span id="cb3-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># artifact, never to its parent — and use flags that FAIL rather than force:</span></span>
<span id="cb3-6"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">mkdir</span> .superpowers/sdd/probe-dir        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># no -p: errors if it already exists</span></span>
<span id="cb3-7"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">rmdir</span> .superpowers/sdd/probe-dir        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># no -rf: errors if it is not empty</span></span></code></pre></div></div>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>It reported the deletion unprompted, in its next message, rather than letting it pass unnoticed. It then checked recoverability honestly and stated plainly that the files were gone, instead of hedging.</p>
<p><strong>What required human expertise:</strong></p>
<p>The question <em>“why did you do that on your own?”</em> — which is not the same question as <em>“what did you delete?”</em>. Claude had framed the incident as a sloppy command. The user reframed it as a <strong>permissions</strong> failure: the problem was not that the command was badly written, it was that Claude removed somebody else’s state without asking at all. A better-written <code>rm</code> would still have been the wrong act.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Two structural reasons, and the second is the load-bearing one.</p>
<ol type="1">
<li><p><strong>Claude generated a probe-and-teardown pair as a single idiom.</strong> <code>mkdir -p X; test; rm -rf X</code> is correct when the probe creates <code>X</code> and catastrophic when it does not — and the two cases are textually identical. Both flags chosen actively suppress the error that would have caught it: <code>-p</code> makes <code>mkdir</code> succeed silently on an existing directory, <code>-rf</code> makes <code>rm</code> succeed silently on a non-empty one. Claude reached for the flags that silence guardrails, in a command whose whole safety depended on one.</p></li>
<li><p><strong>Claude treated “is this destructive?” as a property of the command rather than of the target.</strong> It had just read output listing three files in that directory. Nothing in its model of the task flagged “this path contains other agents’ work” as different from “this path contains my probe file”, because it was reasoning about <em>what the command does</em> and not about <em>whose state it touches</em>. Destructiveness is a fact about ownership, and Claude was only checking syntax.</p></li>
</ol>
<p>A third, smaller point: the unsafe probe was <strong>redundant</strong>. A non-mutating check had already run and returned an answer. Claude escalated from a read to a write because it distrusted the first answer — the correct response to a surprising read is another read, not a write.</p>
<p><strong>Key takeaway:</strong></p>
<p>Before any <code>rm</code>, ask <em>“did I create this?”</em> — not <em>“is this command correct?”</em>. A correct command aimed at somebody else’s files is still the wrong act, and <code>-p</code> and <code>-f</code> exist precisely to hide the difference.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>shell</category>
  <category>data-loss</category>
  <category>claude-code</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-31-rm-rf-cleanup-of-state-it-did-not-create.html</guid>
  <pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Invented Knowledge Belongs in Data, Not Code</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-28-invented-knowledge-belongs-in-data.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>Designing a new feature for <em>Iro 色</em>, a website for exploring the 348 color combinations in Sanzo Wada’s <em>A Dictionary of Color Combinations</em>. The project is deliberately “vibe-coded”: the owner does not read JavaScript, TypeScript, HTML or CSS, and Claude is the sole maintainer of the code.</p>
<p>The feature under design lets a visitor photograph their face and get back the book colors that suit their skin tone. Two palettes were to be produced: one derived from measurements of the photograph by stated geometric rules, and one from the traditional twelve-season system of personal color analysis (“Deep Autumn”, “Cool Summer”, and so on).</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<p>The owner wanted the traditional seasonal output alongside the measured one, so that the result would be useful to someone who had already paid for a professional color analysis:</p>
<blockquote class="blockquote">
<p>but i would like it to still try to guage the person’s 12-color summary. this way it’s still usable with someone who has done an official color analysis. either that or we provide both ways.</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude specced the season palettes as a TypeScript module, <code>src/color/seasons.ts</code>, described in the architecture table as <em>“The twelve regions, the classifier, and the per-season filter.”</em> Each season would own a hard-coded target region — a hue range, a lightness range and a chroma range — and the book’s colors falling inside that region would form that season’s list.</p>
<p>Claude did flag the honesty problem, prominently and unprompted. The spec said:</p>
<blockquote class="blockquote">
<p><strong>These twelve regions have no published source. They are our invention.</strong> That is a real cost of shipping this palette, and it is not to be papered over.</p>
</blockquote>
<p>and required disclosure text on the result page and in the site’s About section stating that the measured palette came from the visitor’s face while the season palette came from a table we wrote.</p>
<p>Claude had also demonstrated the weakness concretely: running both methods over the real 157-color dataset showed they agreed on only 9 colors for one test persona and 8 for another, and Claude’s own season classifier put a light, cool, high-contrast face in “Clear Winter” — a classification a real analyst would likely dispute.</p>
<p>So the problem was identified, measured, and disclosed. It was still in the wrong place.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>maybe we create a separate dataset that maps color to season. this way i can easily change it or have another agent analyze it. and it’ll update in the website.</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong></p>
<p>Claude recognised this as strictly better than the specced approach and restructured the design:</p>
<ul>
<li>The mapping moved to <code>data/curated/seasons.json</code>, hand-editable and read directly by the app — edit the file, refresh the page, palettes change.</li>
<li><code>src/color/seasons.ts</code> became <code>src/core/seasons.ts</code>. With the palettes stored as data, no color science remained in the module, so it belonged in the project’s dependency-free kernel rather than in the layer that may use color libraries.</li>
<li>Membership became many-to-many, since a color can legitimately belong to several seasons.</li>
<li>Classification stayed in code, because it is <em>derived from measurement</em>; membership became data, because it is <em>curated</em>. Naming that split made both halves easier to defend.</li>
<li>The file is seeded once by a script from the original region math, then owned by hand — the script stays available for regeneration but is not part of the build.</li>
<li>A validation test was added covering schema, resolvable color ids, twelve unique seasons, no empty palettes, and total coverage of the classifier’s output space. A file <em>meant</em> to be edited needs that guard more than generated data does.</li>
</ul>
<p>Claude also noted that this amended a standing architecture rule in the project’s <code>CLAUDE.md</code>, which said the app reads only one data file. The amendment preserved the distinction the rule actually protected: <code>data/processed/</code> is generated from a vendored source and must never be hand-edited; <code>data/curated/</code> is authored by hand and never generated.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode json code-with-copy"><code class="sourceCode json"><span id="cb1-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">{</span></span>
<span id="cb1-2">  <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"schemaVersion"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span></span>
<span id="cb1-3">  <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"note"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Hand-curated. No published source — see the spec."</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span></span>
<span id="cb1-4">  <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"seasons"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">[</span></span>
<span id="cb1-5">    <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">{</span></span>
<span id="cb1-6">      <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"id"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"deep-autumn"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span></span>
<span id="cb1-7">      <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"name"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"Deep Autumn"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span></span>
<span id="cb1-8">      <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"undertone"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"warm"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span> <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"depth"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"deep"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span> <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"chroma"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"clear"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">,</span></span>
<span id="cb1-9">      <span class="dt" style="color: #AD0000;
background-color: null;
font-style: inherit;">"colorIds"</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">:</span> <span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">[</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">12</span><span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">,</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">44</span><span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">,</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">91</span><span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">]</span></span>
<span id="cb1-10">    <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">}</span></span>
<span id="cb1-11">  <span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">]</span></span>
<span id="cb1-12"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">}</span></span></code></pre></div></div>
<p>The part of the system that Claude had correctly identified as invented became the part that is easiest to inspect, diff, review and correct — auditable against published sources by someone who cannot read TypeScript, or by a different agent pointed at a single JSON file.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong></p>
<p>Claude identified the epistemic problem without being asked, quantified it by running both methods over the real dataset, showed the owner the disagreement rather than describing it, and volunteered a mitigation. It also correctly separated classification (derived from measurement) from membership (curated) — that distinction survived into the final design.</p>
<p><strong>What required human expertise:</strong></p>
<p>The owner recognised that the <em>form</em> invented knowledge takes determines <em>who is able to correct it later</em>. As a JSON file, twelve dubious lists can be audited by a domain expert, diffed in a pull request, or handed to another agent with a narrow, checkable task. As constants inside a TypeScript module, the same knowledge is frozen behind whoever can safely edit the code — which, in this project, is Claude and no one else.</p>
<p><strong>Why Claude missed it:</strong></p>
<p>Claude treated “invented, with no published source” as a <strong>disclosure</strong> problem and solved it with honest wording. The owner saw it as an <strong>architecture</strong> problem and solved it by changing who holds the pen.</p>
<p>Two structural reasons for the gap. First, Claude optimised for the constraint it had been given — be honest about provenance — and stopped once that constraint was satisfied, rather than asking what <em>else</em> follows from a component being guesswork. Second, constants-in-a-module is an unremarkable default in most codebases, and Claude applied it without weighing the one fact that made it wrong here: the owner does not read code. In a project where the maintainer cannot edit TypeScript, expressing revisable knowledge <em>as</em> TypeScript makes it unrevisable by the only person with the standing to revise it. Claude knew that fact about the project and still failed to apply it to this decision.</p>
<p><strong>Key takeaway:</strong></p>
<p>When you admit that part of a system is guesswork, ask who will need to correct it later and whether they can — the answer, not tidiness, should decide whether that knowledge lives in code or in data.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>maintainability</category>
  <category>plan-review</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-28-invented-knowledge-belongs-in-data.html</guid>
  <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Thirty Lines of Bash Where cat Would Do</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-28-over-engineering-the-ask.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>A repo of reusable GitHub Actions, including one that scans an R project for its package dependencies and writes three CSVs. The action ships in two variants — a composite action and a Docker action — each with a test workflow that runs it against an example R project and then publishes the results with <code>actions/upload-artifact</code>. (The repo’s details, names and paths in this note are generalized.)</p>
<p>The artifact upload was failing on the runners in use, so the workflows were producing correct CSVs that nobody could see.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>“for the [action], let’s also have a step that prints out the values into the action, i’m currently running into a [upload] problem to save out the artifact, but i still want to show that the action is working”</p>
</blockquote>
<p>A debugging request with a clear shape: the upload is broken, I need eyes on the output anyway, put the values in the log.</p>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude added a <code>Show report CSVs</code> step to both test workflows, positioned before the upload step so a failing upload couldn’t hide it. That placement was right. The step itself was 28 lines of bash:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode bash code-with-copy"><code class="sourceCode bash"><span id="cb1-1"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">set</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-euo</span> pipefail</span>
<span id="cb1-2"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">shopt</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-s</span> nullglob</span>
<span id="cb1-3"><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">csvs</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">(</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$REPORT_DIR</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span>/<span class="pp" style="color: #AD0000;
background-color: null;
font-style: inherit;">*</span>.csv<span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">)</span></span>
<span id="cb1-4"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">[</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">${</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">#</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">csvs</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">[@]</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">}</span> <span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">-eq</span> 0 <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">]</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">;</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">then</span></span>
<span id="cb1-5">  <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"No CSVs found in </span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$REPORT_DIR</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;&amp;</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span></span>
<span id="cb1-6">  <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">exit</span> 1</span>
<span id="cb1-7"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">fi</span></span>
<span id="cb1-8"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> f <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">${csvs</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">[@]</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">}</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">;</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">do</span></span>
<span id="cb1-9">  <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">rows</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$((</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$(</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">wc</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-l</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&lt;</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">)</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">))</span>   <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># minus the header</span></span>
<span id="cb1-10">  <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"::group::</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> (</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$rows</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> rows)"</span></span>
<span id="cb1-11">  <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">cat</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span></span>
<span id="cb1-12">  <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"::endgroup::"</span></span>
<span id="cb1-13">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">{</span></span>
<span id="cb1-14">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"### </span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$(</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">basename</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">)</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> (</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$rows</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;"> rows)"</span></span>
<span id="cb1-15">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'```csv'</span></span>
<span id="cb1-16">    <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">head</span> <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">-n</span> 201 <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$f</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span></span>
<span id="cb1-17">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">[</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$rows</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span> <span class="ot" style="color: #003B4F;
background-color: null;
font-style: inherit;">-gt</span> 200 <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">]</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">;</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">then</span></span>
<span id="cb1-18">      <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"... truncated; full contents in the step log"</span></span>
<span id="cb1-19">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">fi</span></span>
<span id="cb1-20">    <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">echo</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'```'</span></span>
<span id="cb1-21">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">}</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;&gt;</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">$GITHUB_STEP_SUMMARY</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"</span></span>
<span id="cb1-22"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">done</span></span></code></pre></div></div>
<p>Features nobody asked for: per-file row counts, collapsible <code>::group::</code> folding, a parallel copy of everything rendered as Markdown into <code>$GITHUB_STEP_SUMMARY</code>, a 200-row truncation cap to stay under the summary’s size limit, and a <code>nullglob</code> guard with an explicit empty-array check.</p>
<p>Claude then tested the block locally against fixture CSVs, confirmed the truncation and empty-directory branches both behaved, and reported the verification results — which made the whole thing feel diligent.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>“why is this so much more complicated than <code>cat</code> the files?”</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong> Conceded the point and audited each addition rather than defending it. Most of it was Claude deciding on its own that the user <em>also</em> wanted results on the run summary page; the truncation logic existed only to serve that self-assigned requirement.</p>
<p>The sharpest finding was the <code>nullglob</code> guard. It looked like the one piece with real defensive value, but it wasn’t: with a plain glob, a missing directory means bash passes <code>*.csv</code> through literally, <code>tail</code> errors, and the step exits 1. Eight lines of “safety” reimplemented behavior bash already had for free.</p>
<p>One piece survived the audit — using <code>tail -n +1</code> instead of <code>cat</code>. It is <code>cat</code> plus a <code>==&gt; filename &lt;==</code> banner per file, which matters here only because the three CSVs have different column headers and would otherwise run together indistinguishably. Claude cut everything else, including the <code>::group::</code> folding.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>“oh i see what you did there. there are 3 groups that expand in the action. that’s a nice touch. let’s put the groups back. the action print out still shows the header, so we can also just drop whatever you were doing with dropping header and just show the raw text with cat. we don’t need tail -1”</p>
</blockquote>
<p>The user had gone and looked at what the <code>::group::</code> markers actually did in the Actions UI, decided the three collapsible sections were worth having, and asked for that one piece back — while confirming the rest should stay gone. With a group label already naming each file, the <code>tail -n +1</code> banner became redundant, and the row count (and its fiddly subtract-one-for-the-header arithmetic) was never wanted.</p>
<p><strong>Turn 2 — Claude:</strong> Restored the <code>::group::</code>/<code>::endgroup::</code> loop with a plain <code>cat</code> body and no counts. Then checked one thing before declaring it done: a bare <code>for</code> loop normally reports the exit status of its <em>last</em> command, which would be the closing <code>echo</code> — so a failing <code>cat</code> could be swallowed and the step could pass green with no output. Testing under <code>bash -e</code> (how GitHub invokes <code>run:</code> steps on Linux) confirmed the missing-file case still exits 1. No guard needed, but worth confirming rather than assuming.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>Five lines of shell per workflow, still placed before the upload step:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode yaml code-with-copy"><code class="sourceCode yaml"><span id="cb2-1"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">      # Print the CSVs to the log, so a run is verifiable even when the artifact</span></span>
<span id="cb2-2"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">      # upload fails. ::group:: makes each file foldable in the Actions UI.</span></span>
<span id="cb2-3"><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">      </span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">-</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> </span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">name</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">:</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;"> Show report CSVs</span></span>
<span id="cb2-4"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">        run</span><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">: </span><span class="ch" style="color: #20794D;
background-color: null;
font-style: inherit;">|</span></span>
<span id="cb2-5">          for f in examples/r/my-project/*.csv; do</span>
<span id="cb2-6">            echo "::group::$f"</span>
<span id="cb2-7">            cat "$f"</span>
<span id="cb2-8">            echo "::endgroup::"</span>
<span id="cb2-9">          done</span></code></pre></div></div>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong> The one genuinely non-obvious part of the task — ordering. Putting the print step <em>before</em> <code>upload-artifact</code> is what makes it useful, because a failing upload aborts the job and would take any later diagnostic down with it. Claude also correctly applied the change to both the composite and Docker variants rather than just the one in front of it.</p>
<p><strong>What required human expertise:</strong> Proportionality, twice, in opposite directions. First the user knew what this step actually was: a throwaway diagnostic for eyeballing three small CSVs while blocked on an unrelated upload problem. Disposable code that a human reads once does not need pagination, structured summary rendering, or defensive guards. Then — having seen what the <code>::group::</code> markers rendered as in the Actions UI — they knew that <em>one</em> of the discarded flourishes was genuinely worth its two lines, and asked for it back. The expertise wasn’t “less code is better.” It was knowing which specific complexity paid for itself in <em>this</em> interface, for <em>this</em> reader.</p>
<p>That second correction is the more interesting one, because Claude over-corrected. Told the code was too complicated, it cut everything, including the good part. Swinging from over-built to stripped-bare is the same failure as the original: substituting a general rule about how much code to write for a judgment about what this particular output needed.</p>
<p><strong>Why Claude missed it:</strong> Several reinforcing reasons, and the mix is the interesting part.</p>
<ol type="1">
<li><em>Default to production quality.</em> Claude treats every code block as if it will be maintained forever. Nothing in the prompt said “this is disposable,” but the situation said it loudly: the user was mid-debugging, blocked, and asking for evidence.</li>
<li><em>Additive bias, and burying the good idea.</em> Every single addition was locally defensible — row counts are informative, a summary view is convenient, truncation prevents hitting a real 1 MiB limit. No individual step felt excessive, so complexity accreted without ever reaching a decision point where Claude asked whether the total was justified. The cost wasn’t only the wasted lines: the one addition that <em>was</em> worth having, the collapsible groups, arrived buried among five that weren’t, so it read as more clutter instead of as a suggestion worth a look. Offering it alone — “want these foldable in the UI?” — would have gotten a yes immediately.</li>
<li><em>Verification created false confidence.</em> Claude tested the bash, watched the branches work, and reported it. But testing establishes that code is <em>correct</em>, never that it should <em>exist</em>. Demonstrating that an unnecessary feature works makes it feel earned, which is exactly backwards.</li>
<li><em>Reasoning about failure abstractly instead of checking.</em> The <code>nullglob</code> guard came from asking “what if there are no files?” and answering from first principles, rather than running the two-word experiment that would have shown the default already failed correctly.</li>
</ol>
<p><strong>Key takeaway:</strong> Ask what happens to a piece of code after it works — code you will read once while debugging should be about as small as the thing it inspects. When you do want to add something beyond the ask, offer it as one visible choice rather than folding five of them into the delivered code, or the good idea gets thrown out with the rest.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>github</category>
  <category>shell</category>
  <category>over-engineering</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-28-over-engineering-the-ask.html</guid>
  <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: When the Hover Still Twitches, Question the Hit-Test, Not the Event Plumbing</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-20-snap-to-nearest-object.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>Building an interactive visualization of Sanzo Wada’s <em>A Dictionary of Color Combinations</em> — a circular D3 chord diagram where 157 colors sit as arcs around a ring and ~1,000 ribbons connect the colors that appear together in the book’s palettes. Hovering a ribbon or arc is supposed to dim everything else so the connection stands out. The whole thing is a static site the owner maintains by directing Claude (“vibe-coded” — the owner doesn’t read the JS/CSS themselves), so the interaction has to <em>feel</em> right on the owner’s screen, which is the one place the automated tests can’t see.</p>
<p>A runnable minimal reproduction accompanies this note: <a href="2026-07-20-snap-to-nearest-object-repro.html"><code>2026-07-20-snap-to-nearest-object-repro.html</code></a> — a small chord wheel with a toggle between the buggy “exact hit-testing” hover and the fixed “snap to nearest” hover. Open it and drag the cursor slowly across the lines inside the circle in each mode.</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<blockquote class="blockquote">
<p>the wheel flickers a lot when hovering over. sometimes even flickering completely off where no wheel is seen. let’s fix the weel hover performance before i’m able to use it in a way for next iteration.</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude ran a systematic root-cause pass and found real problems in the original hover code:</p>
<ul>
<li>Every one of the ~1,150 ribbon and arc elements had its <strong>own</strong> <code>mouseenter</code>/<code>mouseleave</code> listener, and each fire ran a full-scene <code>classed('dimmed', …)</code> sweep over <em>all</em> ribbons and arcs with a 200 ms CSS transition. Crossing the hundreds of tiny paths under the cursor restarted every transition constantly — the flicker.</li>
<li>Ribbon hover dimmed the <strong>entire</strong> wheel to near-invisible; since the disc interior is wall-to-wall ribbons, the wheel visually vanished whenever the cursor was inside it — the “flickering completely off.”</li>
<li><code>mix-blend-mode: multiply</code> on all ~1,000 ribbons piled compositing cost on top.</li>
</ul>
<p>Claude rebuilt hover with <strong>delegated</strong> listeners (three handlers on the wheel group instead of ~1,150), <strong>keyed</strong> state so redundant events are no-ops, and dimming driven by one container class plus a small “hot” set instead of a full-scene sweep. This genuinely removed most of the flicker. But in the process Claude <em>also dropped ribbon-hover dimming entirely</em> — an over-correction, on the theory that dimming was the culprit.</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong></p>
<blockquote class="blockquote">
<p>better. but now when i over over one of the lines inside the circle, it doesn’t really stand out. before at least the other colors faded out a bit. i liked that behaviour, you can see the color connections</p>
</blockquote>
<p><strong>Turn 1 — Claude:</strong> Conceded the over-correction. The flicker’s cause had been the event thrashing, <em>not</em> the dimming concept — and now that hover used keyed delegated events (moving between ribbons swaps state directly, never clearing then re-dimming), the “connections stand out” behavior was safe to restore. Claude brought back ribbon-hover dimming: hovering a ribbon dims the rest of the wheel and keeps that ribbon <strong>plus the two arcs it connects</strong> bright.</p>
<p><strong>Turn 2 — User:</strong></p>
<blockquote class="blockquote">
<p>let’s do another thinking pass about a solution for the color wheel to make it smoother. in the colors only verion (not shades, families, groups) it still gets twitchy when i am hovering over.</p>
<p>i think this is because sometimes as you are hovering over the inner lines you rmouse doesn’t actually land on anyplace, so it flashes back into the full wheel view. let’s have the mouse always snap to the nearest object (line inside or box outside) it hinkt his will help. what do you think? or do you think we need to use a different library?</p>
</blockquote>
<p><strong>Turn 2 — Claude:</strong> This diagnosis was the key that Claude’s two prior fixes had circled without grabbing. Claude confirmed the real root cause: an SVG <code>&lt;g&gt;</code> has <strong>no fill of its own</strong> — it is only “hovered” when the pointer is literally over one of its child shapes. At the Colors level the disc is ~1,000 thin ribbons <em>with gaps between them</em>, plus a hollow center. Every time the cursor slipped into a gap, it was over nothing, the group’s <code>pointerleave</code> fired, the dim cleared, and the wheel snapped back to full view — then the next ribbon re-dimmed it. That gap-flash was the residual twitch.</p>
<p>On the library question: no switch needed. Any renderer has the same “cursor is in a gap” problem, and D3 <em>already bundles</em> <code>d3-delaunay</code> for exactly the nearest-neighbor lookup the fix requires. Claude implemented the owner’s instinct directly:</p>
<ul>
<li>A transparent backing disc so the pointer is always “within” the wheel and <code>pointerleave</code> only fires at the true outer edge — no interior dead zones.</li>
<li>Hover resolves by <strong>geometry from the cursor position</strong>, not which path it lands on: outside the inner radius it snaps to the arc at that angle; inside, it snaps to the nearest ribbon via a Delaunay index of points sampled along each ribbon’s centerline.</li>
<li>Resolution is keyed and throttled to one update per animation frame, so the highlight only changes when the nearest object changes — it glides instead of strobing. Clicks snap the same way (a click in a gap selects the nearest line or box).</li>
</ul>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>The before/after, reduced to the essential difference in <em>what counts as a hit</em>:</p>
<p><strong>Before — exact hit-testing.</strong> Hover depends on the pointer being over an actual shape, so the gaps between shapes are dead zones that fire a clear:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode js code-with-copy"><code class="sourceCode javascript"><span id="cb1-1">ribbons</span>
<span id="cb1-2">  <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">on</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'mouseenter'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">function</span> (_e<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> d) {</span>
<span id="cb1-3">    ribbons<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">classed</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'dimmed'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> r <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">=&gt;</span> r <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!==</span> d)          <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// touch all ~1000 ribbons</span></span>
<span id="cb1-4">    arcs<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">classed</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'dimmed'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> (_a<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> i) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">=&gt;</span> i <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!==</span> d<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">source</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">index</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&amp;&amp;</span> i <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">!==</span> d<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">target</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">index</span>)</span>
<span id="cb1-5">  })</span>
<span id="cb1-6">  <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">on</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'mouseleave'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> clearHover)   <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// fires the moment the cursor enters a gap</span></span></code></pre></div></div>
<p><strong>After — snap to nearest.</strong> A backing disc removes the dead zones, and the cursor <em>position</em> (not the element under it) picks the nearest object:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode js code-with-copy"><code class="sourceCode javascript"><span id="cb2-1">g<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">append</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'circle'</span>)<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">attr</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'class'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'wheel-hit'</span>)<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">attr</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'r'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> HIT_R)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// no dead gaps</span></span>
<span id="cb2-2"></span>
<span id="cb2-3"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// build once per render: nearest-ribbon index from centerline samples</span></span>
<span id="cb2-4"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">const</span> delaunay <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> d3<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">Delaunay</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">from</span>(centerlineSamples)</span>
<span id="cb2-5"></span>
<span id="cb2-6">g<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">on</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'pointermove'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> (<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">event</span>) <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">=&gt;</span> {</span>
<span id="cb2-7">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">const</span> [x<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> y] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> d3<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">pointer</span>(<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">event</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> gNode)</span>
<span id="cb2-8">  <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">const</span> nearest <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">Math</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">hypot</span>(x<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> y) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">&gt;=</span> INNER</span>
<span id="cb2-9">    <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">?</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">arcAtAngle</span>(<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">Math</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">atan2</span>(x<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span>y))     <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// outside the ring: arc by angle</span></span>
<span id="cb2-10">    <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">:</span> <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">ribbonAt</span>(delaunay<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">find</span>(x<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> y))     <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// inside: nearest ribbon centerline</span></span>
<span id="cb2-11">  <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> (nearest<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">key</span> <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">===</span> hoverKey) <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span>  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// keyed → glides, never strobes</span></span>
<span id="cb2-12">  <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">highlight</span>(nearest)</span>
<span id="cb2-13">})</span>
<span id="cb2-14">g<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">.</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">on</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">'pointerleave'</span><span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">,</span> clearHover)        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">// only at the true outer edge</span></span></code></pre></div></div>
<p>(One supporting detail: the wheel’s decorative tilt moved from a CSS <code>transform</code> on the <code>&lt;svg&gt;</code> onto the SVG group, so <code>d3.pointer</code>’s <code>getScreenCTM</code> math includes it and the pointer-to-angle conversion stays exact.)</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong> The systematic debugging was sound and each fix was a real improvement. Claude correctly identified the event thrashing (per-element listeners doing full-scene sweeps), the pathological whole-wheel dimming, and the blend-mode cost — and the delegated, keyed rewrite removed most of the flicker. When the owner pushed back on the lost dimming, Claude correctly re-reasoned that the dimming was never the problem and restored it safely.</p>
<p><strong>What required human expertise:</strong> The owner’s mental model of the <em>symptom</em> — “sometimes as you are hovering over the inner lines your mouse doesn’t actually land on anyplace, so it flashes back into the full wheel view.” That sentence names the true root cause (the cursor falling into gaps between shapes) and the right fix (snap to the nearest object so a hit never depends on landing exactly on a shape) in one breath. The owner also asked the sharpening meta-question — “or do you think we need to use a different library?” — which forced the correct framing: this is not a rendering-library problem at all.</p>
<p><strong>Why Claude missed it:</strong> Claude kept optimizing the <em>mechanism</em> of hover (how events are attached and batched) while never questioning the <em>model</em> underneath it: that “hovering” means “the pointer is over a shape.” In a scene built from ~1,000 thin shapes separated by gaps, that assumption is false a large fraction of the time — but it’s invisible from the code, because the code only describes what happens <em>when</em> you’re over a shape, never what happens in the space between. Claude also had no runtime feel for the interaction; the tests all passed, and only a human moving a real cursor through the gaps could perceive that the dead space between shapes was where the bug lived. So Claude made the existing model faster and smoother twice, when the fix was to change the model — from “exact hit” to “nearest object.”</p>
<p><strong>Key takeaway:</strong> When an interaction still twitches after you’ve smoothed the event handling, question the hit-testing model itself, not just the plumbing — “nearest object” beats “exact hit” whenever the target is made of thin shapes and gaps, and the person moving the real cursor often sees the gap before you do.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>dataviz</category>
  <category>d3</category>
  <category>debugging</category>
  <category>ux</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-20-snap-to-nearest-object.html</guid>
  <pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Learning Moment: Don’t Reach for a Separate Config File Until You Know What’s Actually Secret</title>
  <link>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-03-compose-file-inline-config.html</link>
  <description><![CDATA[ 





<section id="context" class="level2">
<h2 class="anchored" data-anchor-id="context">Context</h2>
<p>Designing a Dockerized Tailscale client for a dotfiles repo: a container that joins a <em>second</em>, separate private network so SSH port-forwards to a remote server stay available, without disturbing the host machine’s existing connection to its primary Tailscale network. The design was being built as a Dockerfile + <code>docker-compose.yml</code>, with a stated requirement that adding new forwarded hosts/ports later shouldn’t require editing the Dockerfile, and that local host-side ports needed to stay easy to change (to avoid colliding with other local apps).</p>
</section>
<section id="the-initial-ask" class="level2">
<h2 class="anchored" data-anchor-id="the-initial-ask">The Initial Ask</h2>
<p>The user asked how the list of SSH forwards (remote host, remote port, mapped port) should be defined so that adding a new destination later is just an edit, not a rebuild. Claude offered a choice between:</p>
<ol type="1">
<li>A <code>.env</code> file read by <code>docker-compose</code>, parsed by a startup script into multiple <code>-L</code> flags for an <code>autossh</code> tunnel.</li>
<li>A separate mounted config file (e.g.&nbsp;<code>forwards.conf</code>), one <code>remote_host:remote_port:local_port</code> entry per line.</li>
</ol>
<p>The user picked option 2 (the file), but then paused and questioned the premise:</p>
<blockquote class="blockquote">
<p>“let’s go with option 2. the .env file may not get checked into git and i’ll lose the reference on how to set it up. i can also have those put directly into the compose file? why does it need to be a separate file?”</p>
</blockquote>
</section>
<section id="claudes-initial-response" class="level2">
<h2 class="anchored" data-anchor-id="claudes-initial-response">Claude’s Initial Response</h2>
<p>Claude had proposed the file-based config purely as a way to make the forwards list easy to extend later — it hadn’t separately asked whether the values involved (hostnames, ports) were actually sensitive, and defaulted to a “config file” pattern that’s more typically reached for when secrets are involved (which need to stay out of version control).</p>
</section>
<section id="the-exchange" class="level2">
<h2 class="anchored" data-anchor-id="the-exchange">The Exchange</h2>
<p><strong>Turn 1 — User:</strong> Picked the config-file option, but flagged the practical downside: <code>.env</code> files are conventionally gitignored, so the setup instructions would live in a file that might never actually get committed — defeating the point of having a durable reference. Asked directly why the values couldn’t just live in <code>docker-compose.yml</code> itself.</p>
<p><strong>Turn 1 — Claude:</strong> Agreed the concern was valid, and re-examined the actual constraint: none of the forward definitions (hostnames, ports) needed hiding in this private repo — the only thing that needed real host-specific flexibility was the <em>local</em> port mapping (to dodge port collisions on a given machine). (That only holds because the repo is private: hostnames on someone else’s network are still infrastructure details, and don’t belong in a public repo.) Revised the design to: - Put the forwards list as environment variables directly inside the tracked <code>docker-compose.yml</code> (single source of truth, always in git). - Use Docker Compose’s built-in override-file mechanism (<code>docker-compose.override.yml</code>) — untracked, per-machine — for the one thing that genuinely varied by machine: local port numbers.</p>
</section>
<section id="the-final-solution" class="level2">
<h2 class="anchored" data-anchor-id="the-final-solution">The Final Solution</h2>
<p>One tracked <code>docker-compose.yml</code> containing the forwards list and sensible default port mappings. An optional, untracked <code>docker-compose.override.yml</code> per machine, used only when that machine had an actual local port conflict — no custom <code>.env</code> parsing logic, no separate config file to lose track of.</p>
</section>
<section id="the-lesson" class="level2">
<h2 class="anchored" data-anchor-id="the-lesson">The Lesson</h2>
<p><strong>What Claude got right:</strong> Correctly identified that the forwards list should be config-driven rather than hardcoded into the Dockerfile, and that local port mappings needed to stay independently changeable.</p>
<p><strong>What required human expertise:</strong> The user recognized that the proposed indirection (a <code>.env</code> file or a mounted config file) introduced a <em>new</em> problem — a file that conventionally doesn’t get committed — that worked against the actual goal of having a durable, findable reference for how the setup works. They also knew that Docker Compose already has a native mechanism (override files) purpose-built for per-machine variation, so no custom solution was needed at all.</p>
<p><strong>Why Claude missed it:</strong> Claude pattern-matched “this value might need to vary” to the generic “put it in a config/env file” convention, which is the right move for <em>secrets</em>, without first checking whether these particular values were secret. Optimized for the stated goal (make it easy to add forwards later) without asking the sharper question: which parts of this config are actually machine- or environment-specific, versus just… configuration that’s fine to check in?</p>
<p><strong>Key takeaway:</strong> Before reaching for indirection (a separate config file, a <code>.env</code>, an extra layer), ask what’s actually secret or environment-specific. If nothing is, put the values directly in the tracked file — and if something <em>is</em> genuinely environment-specific, look for the tool’s native mechanism for that (like Compose override files) before inventing your own.</p>


</section>

 ]]></description>
  <category>claude</category>
  <category>learning</category>
  <category>ai-collaboration</category>
  <category>docker</category>
  <category>security</category>
  <category>over-engineering</category>
  <category>dotfiles</category>
  <category>plan-review</category>
  <guid>https://chendaniely.github.io/genai-learning-moments/posts/2026-07-03-compose-file-inline-config.html</guid>
  <pubDate>Fri, 03 Jul 2026 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
