<?xml version="1.0" encoding="utf-8" standalone="yes"?><?xml-stylesheet type="text/xsl" href="https://perrotta.dev/rss.xsl"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>¬ just serendipity 🍀</title>
    <link>https://perrotta.dev/</link>
    <description>Recent content on ¬ just serendipity 🍀</description>
    <generator>Hugo</generator>
    <language>en-us</language>
    <managingEditor>serendipity@perrotta.dev (Thiago Perrotta)</managingEditor>
    <webMaster>serendipity@perrotta.dev (Thiago Perrotta)</webMaster>
    <copyright>© 2013 - 2026 Thiago Perrotta ·
  a fork of [hugo ʕ•ᴥ•ʔ bear](https://github.com/janraasch/hugo-bearblog/)
</copyright>
    <atom:link href="https://perrotta.dev/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>★ Goodbye
      </title>
      <link>https://perrotta.dev/2026/09/goodbye/</link>
      <pubDate>Wed, 30 Sep 2026 11:32:43 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bestof</category>
      <category>dev</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/09/goodbye/</guid>
      <description>&lt;p&gt;♠ Today is my last day at &lt;a href=&#34;http://tulip.co/&#34;&gt;Tulip&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;I should write about my experience sometime.&lt;/p&gt;&#xA;&lt;p&gt;For now, I&amp;rsquo;ll just say that I had a better experience here than in Google&#xA;Germany&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;https://perrotta.dev/2026/09/goodbye/#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#xA;&lt;p&gt;Pull Request (PR) stats by year of PR creation:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;460 (2024)&lt;/li&gt;&#xA;&lt;li&gt;1,096 (2025)&lt;/li&gt;&#xA;&lt;li&gt;1,468 (2026 to date)&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Clearly LLM assistance has significantly increased my throughput.&lt;/p&gt;&#xA;&lt;p&gt;Now it&amp;rsquo;s time to prepare for what&amp;rsquo;s coming next. ∎&lt;/p&gt;&#xA;&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;&#xA;&lt;hr&gt;&#xA;&lt;ol&gt;&#xA;&lt;li id=&#34;fn:1&#34;&gt;&#xA;&lt;p&gt;By far.&amp;#160;&lt;a href=&#34;https://perrotta.dev/2026/09/goodbye/#fnref:1&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: Goodbye&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bestof/&#34;&gt;#bestof&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>offboarding: export your own work before the laptop is wiped
      </title>
      <link>https://perrotta.dev/2026/09/offboarding-export-your-own-work-before-the-laptop-is-wiped/</link>
      <pubDate>Wed, 30 Sep 2026 11:02:24 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <guid>https://perrotta.dev/2026/09/offboarding-export-your-own-work-before-the-laptop-is-wiped/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: what is worth taking home on the last day at work,&#xA;without taking the company&amp;rsquo;s code or data?&lt;/p&gt;&#xA;&lt;p&gt;The line I drew: my work history, my own words, my contacts, my notes. Not&#xA;source code, not runbooks, not customer data. Everything else the company keeps.&#xA;Metadata about my contributions is mine to remember; the artifacts are theirs.&lt;/p&gt;&#xA;&lt;p&gt;I pointed an AI harness at the connectors I already had (GitHub, Jira,&#xA;Confluence, Gmail, Drive, Calendar, Slack, the HR directory) and asked it to&#xA;build an inventory. It landed in one folder:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% eza -T -L1 ~/Downloads/offboarding&#xA;offboarding&#xA;├── README.md&#xA;├── resume-highlights.md&#xA;├── merged-prs.csv&#xA;├── jira-done.jsonl&#xA;├── jira-epics.md&#xA;├── contacts.csv&#xA;├── gmail-index.md&#xA;├── emails/&#xA;├── drive-index.md&#xA;├── drive-files/&#xA;├── confluence/&#xA;├── calendar-summary.md&#xA;├── slack-export.md&#xA;├── github-insights.md&#xA;├── chrome-bookmarks-work-profile.html&#xA;└── claude-memory/&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The GitHub part is pure API, no clone. Merged PRs, paginated once:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% gh api graphql --paginate -f query=&amp;#39;query($c: String) { viewer {&#xA;    pullRequests(first: 100, states: MERGED, after: $c) {&#xA;      pageInfo { hasNextPage endCursor }&#xA;      nodes { mergedAt repository { nameWithOwner } title additions deletions }&#xA;    } } }&amp;#39; --jq &amp;#39;.data.viewer.pullRequests.nodes[]&amp;#39; | jq -s . &amp;gt; merged_prs.json&#xA;% jq -r &amp;#39;.[].mergedAt[:4]&amp;#39; merged_prs.json | sort | uniq -c&#xA; 892 2024&#xA;1160 2025&#xA;1436 2026&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Titles, dates, and line counts only. That is enough to write a resume bullet&#xA;later; the diffs stay behind. Same idea for Jira (&lt;code&gt;assignee = currentUser() AND statusCategory = Done&lt;/code&gt;) and for Confluence (&lt;code&gt;creator = currentUser()&lt;/code&gt;).&lt;/p&gt;&#xA;&lt;p&gt;Two things were clearly mine and would have been lost: the &amp;ldquo;working with me&amp;rdquo;&#xA;email from week one, and a first-day checklist I mailed to myself. Both went&#xA;into &lt;code&gt;emails/&lt;/code&gt; verbatim. The Slack kudos channel got the same treatment,&#xA;because nobody will write a reference from memory two years from now.&lt;/p&gt;&#xA;&lt;p&gt;The README in the folder lists what was deliberately not exported: source code,&#xA;PR descriptions, runbook bodies, performance reviews. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: offboarding: export your own work before the laptop is wiped&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>JIRA: backfill tickets from merged pull requests
      </title>
      <link>https://perrotta.dev/2026/09/jira-backfill-tickets-from-merged-pull-requests/</link>
      <pubDate>Tue, 29 Sep 2026 11:21:17 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <guid>https://perrotta.dev/2026/09/jira-backfill-tickets-from-merged-pull-requests/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: how can I catch up JIRA when pull requests have already&#xA;(been) merged without tickets?&lt;/p&gt;&#xA;&lt;p&gt;I wrote a local &lt;code&gt;/backfill-jira&lt;/code&gt; skill for my AI harnesses. It compares recent&#xA;merged PRs with existing issues before it creates anything. This is its&#xA;structure; internal projects, repositories, queries, and field IDs are omitted:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% rg -n &amp;#39;^### Phase&amp;#39; ~/.claude/skills/backfill-jira/SKILL.md&#xA;31:### Phase A — gather &amp;#43; reconcile (read-only)&#xA;72:### Phase B — create the backfill tickets&#xA;125:### Phase C — confirm&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The unit of work is a &lt;em&gt;theme&lt;/em&gt;, not a PR. Several PRs can implement one change;&#xA;one PR can also be routine enough to need no ticket. The skill checks PR&#xA;references and matching issue summaries before declaring a gap. Existing&#xA;backlog issues count as coverage.&lt;/p&gt;&#xA;&lt;p&gt;The report rule in the skill is explicit:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;4. **Report.** Print a table of every PR → (existing ticket | NEW theme-N | skipped-routine) and a&#xA;   separate list of the gap themes to create. The counts must reconcile: covered &amp;#43; new &amp;#43; skipped =&#xA;   total PRs. Don&amp;#39;t let &amp;#34;get every PR to a theme&amp;#34; pressure you into merging unrelated skipped PRs&#xA;   into a theme just to shrink the skipped list.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Phase B creates one ticket for each uncovered theme, marks work as complete,&#xA;and reads the status back. It also checks the sprint assignment after writing&#xA;it. Neither a successful create response nor an attempted status change is&#xA;proof that the final fields stuck.&lt;/p&gt;&#xA;&lt;p&gt;The skill deliberately leaves routine work alone:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;- Don&amp;#39;t create per-PR tickets by default — one ticket per theme keeps signal high. **Never create a&#xA;  &amp;#34;grab bag&amp;#34; / &amp;#34;catch-all&amp;#34; / &amp;#34;hygiene&amp;#34; ticket that lumps together unrelated PRs** just to get to&#xA;  zero gaps — each ticket must describe one coherent, describable piece of work. If a PR is&#xA;  genuinely routine and not worth tracking on its own (a version bump, a dependency bump, a typo&#xA;  fix) and doesn&amp;#39;t fit an existing theme, leave it **uncovered**: list it in the report as *skipped&#xA;  (routine, no ticket)* rather than inventing a ticket to absorb it.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A second pass over the same window checks whether the new tickets now cover&#xA;the gaps. Zero new gaps matters more than zero skipped PRs. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: JIRA: backfill tickets from merged pull requests&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>typst: reusable polylux slides with section navigation
      </title>
      <link>https://perrotta.dev/2026/09/typst-reusable-polylux-slides-with-section-navigation/</link>
      <pubDate>Mon, 28 Sep 2026 23:58:53 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>meta</category>
      <guid>https://perrotta.dev/2026/09/typst-reusable-polylux-slides-with-section-navigation/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/02/making-a-slides-presentation-in-2026/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: how can I reuse a Typst slide layout without rebuilding its&#xA;section navigation every time?&lt;/p&gt;&#xA;&lt;p&gt;Here is the complete &lt;a href=&#34;https://polylux.dev/book/&#34;&gt;Polylux&lt;/a&gt; template. Put these&#xA;three files in one directory. &lt;code&gt;theme.typ&lt;/code&gt; owns the Metropolis-inspired layout&#xA;and Frankfurt-style navigation: section names above one dot per content slide.&#xA;Its state records the section counts and active slide. &lt;code&gt;new-section&lt;/code&gt; creates a&#xA;divider without a dot; &lt;code&gt;slide&lt;/code&gt; adds a dot; &lt;code&gt;untracked-slide&lt;/code&gt; is for title or&#xA;closing pages.&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typst&#34;&gt;// =============================================================================&#xA;// Metropolis theme &amp;#43; Frankfurt-style navigation bar with mini-frame dots&#xA;// =============================================================================&#xA;// Visual style: Metropolis (navy blue, light blue accent, clean typography)&#xA;// Navigation:   Frankfurt (section names &amp;#43; circle dots for slide progress)&#xA;// =============================================================================&#xA;&#xA;#import &amp;#34;@preview/polylux:0.4.0&amp;#34;: *&#xA;#let _polylux-slide = slide&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Colors — Navy blue palette&#xA;// ---------------------------------------------------------------------------&#xA;#let dark-teal = rgb(&amp;#34;#1a2744&amp;#34;)    // navy blue (primary dark)&#xA;#let bright = rgb(&amp;#34;#4a90d9&amp;#34;)       // accent / emphasis (medium blue)&#xA;#let brighter = rgb(&amp;#34;#a8c4e0&amp;#34;)     // muted light blue&#xA;#let bg = white.darken(2%)&#xA;#let fg = dark-teal&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Navigation state — tracks slides per section for mini-frame dots&#xA;// ---------------------------------------------------------------------------&#xA;#let _nav-sections = state(&amp;#34;nav-sections&amp;#34;, ())       // [{name, count}, ...]&#xA;#let _nav-current-sec = state(&amp;#34;nav-current-sec&amp;#34;, -1)  // index into sections&#xA;#let _nav-current-frame = state(&amp;#34;nav-current-frame&amp;#34;, 0)&#xA;&#xA;#let _begin-section(name) = {&#xA;  _nav-sections.update(secs =&amp;gt; {&#xA;    secs.push((name: name, count: 0))&#xA;    secs&#xA;  })&#xA;  _nav-current-sec.update(i =&amp;gt; i &amp;#43; 1)&#xA;  _nav-current-frame.update(_ =&amp;gt; 0)&#xA;}&#xA;&#xA;#let _count-frame() = {&#xA;  context {&#xA;    let sec-idx = _nav-current-sec.get()&#xA;    if sec-idx &amp;gt;= 0 {&#xA;      _nav-current-frame.update(f =&amp;gt; f &amp;#43; 1)&#xA;      _nav-sections.update(secs =&amp;gt; {&#xA;        if sec-idx &amp;lt; secs.len() {&#xA;          secs.at(sec-idx).count &amp;#43;= 1&#xA;        }&#xA;        secs&#xA;      })&#xA;    }&#xA;  }&#xA;}&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Navigation header — section names &amp;#43; mini-frame circles&#xA;// ---------------------------------------------------------------------------&#xA;#let _nav-header() = context {&#xA;  let all-secs = _nav-sections.final()&#xA;  let curr-sec = _nav-current-sec.get()&#xA;  let curr-frame = _nav-current-frame.get()&#xA;&#xA;  if all-secs.len() == 0 { return }&#xA;&#xA;  set text(fill: white, size: 0.5em, weight: &amp;#34;bold&amp;#34;)&#xA;&#xA;  let n = all-secs.len()&#xA;&#xA;  // Row 1: Section names&#xA;  let names-row = all-secs.enumerate().map(((i, sec)) =&amp;gt; {&#xA;    let is-current = i == curr-sec&#xA;    let is-past = i &amp;lt; curr-sec&#xA;    let name-color = if is-current { white } else if is-past { white.darken(10%) } else { white.darken(35%) }&#xA;    align(center, text(fill: name-color, sec.name))&#xA;  })&#xA;&#xA;  // Row 2: Mini-frame dots (aligned under each section name)&#xA;  let dots-row = all-secs.enumerate().map(((i, sec)) =&amp;gt; {&#xA;    let is-current = i == curr-sec&#xA;    let is-past = i &amp;lt; curr-sec&#xA;&#xA;    let dots = range(sec.count).map(j =&amp;gt; {&#xA;      let frame-num = j &amp;#43; 1&#xA;      let dot-fill = if is-past {&#xA;        white.darken(10%)&#xA;      } else if is-current and frame-num &amp;lt; curr-frame {&#xA;        white.darken(5%)&#xA;      } else if is-current and frame-num == curr-frame {&#xA;        bright&#xA;      } else {&#xA;        none&#xA;      }&#xA;      let dot-stroke = if dot-fill == none { 0.6pt &amp;#43; white.darken(30%) } else { none }&#xA;      let fill = if dot-fill == none { dark-teal } else { dot-fill }&#xA;      box(circle(radius: 2.5pt, fill: fill, stroke: dot-stroke))&#xA;    })&#xA;    align(center, dots.join(h(2.5pt)))&#xA;  })&#xA;&#xA;  // Combine into a two-row grid&#xA;  block(width: 100%, fill: dark-teal, inset: (x: 0.6em, y: 0.3em),&#xA;    grid(&#xA;      columns: (1fr,) * n,&#xA;      row-gutter: 0.25em,&#xA;      ..names-row,&#xA;      ..dots-row,&#xA;    )&#xA;  )&#xA;}&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Slide title header (Metropolis style — below nav bar)&#xA;// ---------------------------------------------------------------------------&#xA;#let _title-header = toolbox.next-heading(h =&amp;gt; {&#xA;  show: toolbox.full-width-block.with(fill: dark-teal, inset: (x: 1em, y: 0.6em))&#xA;  set align(horizon)&#xA;  set text(fill: bg, size: 1em)&#xA;  strong(h)&#xA;})&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Footer — Metropolis style&#xA;// ---------------------------------------------------------------------------&#xA;#let _the-footer(content) = {&#xA;  set text(size: 0.7em)&#xA;  show: pad.with(0.5em)&#xA;  set align(bottom)&#xA;  context text(fill: fg.lighten(40%), content)&#xA;  h(1fr)&#xA;  toolbox.slide-number&#xA;}&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Progress bar — Metropolis style (blue)&#xA;// ---------------------------------------------------------------------------&#xA;#let progress-bar = toolbox.progress-ratio(ratio =&amp;gt; {&#xA;  set grid.cell(inset: (y: 0.03em))&#xA;  grid(&#xA;    columns: (ratio * 100%, 1fr),&#xA;    grid.cell(fill: bright)[],&#xA;    grid.cell(fill: brighter)[],&#xA;  )&#xA;})&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Public API: slide (tracked), new-section, focus, alert, note, divider&#xA;// ---------------------------------------------------------------------------&#xA;&#xA;/// Content slide — automatically tracked in the navigation bar.&#xA;/// Use `= Title` as the first line for the slide title.&#xA;#let slide(body) = {&#xA;  _count-frame()&#xA;  _polylux-slide(body)&#xA;}&#xA;&#xA;/// Section divider slide — registers a new section and shows a title card.&#xA;/// Not counted in mini-frame dots.&#xA;#let new-section(name) = {&#xA;  _begin-section(name)&#xA;  _polylux-slide({&#xA;    set page(header: none, footer: none)&#xA;    toolbox.register-section(name)&#xA;    show: pad.with(20%)&#xA;    set text(size: 1.5em, fill: fg)&#xA;    name&#xA;    progress-bar&#xA;  })&#xA;}&#xA;&#xA;/// Focus slide — inverted colors for key messages.&#xA;#let focus(body) = context {&#xA;  set page(header: none, footer: none, fill: dark-teal, margin: 2em)&#xA;  set text(fill: bg, size: 1.5em)&#xA;  set align(center &amp;#43; horizon)&#xA;  body&#xA;}&#xA;&#xA;/// Untracked slide — for title, closing, etc. No navigation counting.&#xA;#let untracked-slide(body) = _polylux-slide(body)&#xA;&#xA;/// Blue divider line&#xA;#let divider = line(length: 100%, stroke: 0.1em &amp;#43; bright)&#xA;&#xA;/// Alert text (blue bold)&#xA;#let alert(body) = text(fill: bright, weight: &amp;#34;bold&amp;#34;, body)&#xA;&#xA;/// Two-column layout helper — e.g. diagram left, bullets right&#xA;#let side-by-side(left, right, ratio: 1fr) = {&#xA;  grid(&#xA;    columns: (ratio, 1fr),&#xA;    gutter: 1em,&#xA;    left, right,&#xA;  )&#xA;}&#xA;&#xA;/// Captioned image from assets directory&#xA;#let fig(path, caption: none, width: 80%) = {&#xA;  align(center,&#xA;    figure(&#xA;      image(&amp;#34;assets/&amp;#34; &amp;#43; path, width: width),&#xA;      caption: if caption != none { text(size: 0.75em, caption) },&#xA;    )&#xA;  )&#xA;}&#xA;&#xA;/// Speaker notes — pass `--input notes=false` to hide&#xA;#let note(body) = {&#xA;  let hidden = sys.inputs.at(&amp;#34;notes&amp;#34;, default: &amp;#34;true&amp;#34;) == &amp;#34;false&amp;#34;&#xA;  if not hidden {&#xA;    place(bottom &amp;#43; left, dx: 0pt, dy: 0.8em)[&#xA;      #block(&#xA;        width: 100%,&#xA;        inset: 6pt,&#xA;        fill: rgb(&amp;#34;#fef9c3&amp;#34;).transparentize(20%),&#xA;        radius: 4pt,&#xA;        text(size: 10pt, style: &amp;#34;italic&amp;#34;, fill: fg, body),&#xA;      )&#xA;    ]&#xA;  }&#xA;}&#xA;&#xA;// ---------------------------------------------------------------------------&#xA;// Theme setup — call as:  #show: theme.setup.with(...)&#xA;// ---------------------------------------------------------------------------&#xA;#let setup(&#xA;  footer: none,&#xA;  navbar: true,&#xA;  text-font: &amp;#34;Avenir Next&amp;#34;,&#xA;  math-font: &amp;#34;Avenir Next&amp;#34;,&#xA;  code-font: &amp;#34;IBM Plex Mono&amp;#34;,&#xA;  text-size: 22pt,&#xA;  body,&#xA;) = {&#xA;  set page(&#xA;    paper: &amp;#34;presentation-16-9&amp;#34;,&#xA;    fill: bg,&#xA;    margin: (top: if navbar { 4.5em } else { 3em }, left: 1.2em, right: 1.2em, bottom: 1.8em),&#xA;    header: {&#xA;      set align(top)&#xA;      if navbar { toolbox.full-width-block[#_nav-header()] }&#xA;      _title-header&#xA;    },&#xA;    footer: _the-footer(footer),&#xA;  )&#xA;&#xA;  set text(&#xA;    font: text-font,&#xA;    size: text-size,&#xA;    fill: fg,&#xA;  )&#xA;  set strong(delta: 100)&#xA;  show math.equation: set text(font: math-font)&#xA;  show raw: set text(font: code-font)&#xA;  show raw.where(block: true): it =&amp;gt; block(&#xA;    width: 100%,&#xA;    fill: luma(240),&#xA;    inset: 10pt,&#xA;    radius: 3pt,&#xA;    stroke: 0.5pt &amp;#43; luma(200),&#xA;    it,&#xA;  )&#xA;  set align(horizon)&#xA;  set list(spacing: 1.4em)&#xA;  set enum(spacing: 1.4em)&#xA;  show emph: it =&amp;gt; text(fill: bright, it.body)&#xA;  show heading.where(level: 1): _ =&amp;gt; none&#xA;&#xA;  body&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typst&#34;&gt;#import &amp;#34;theme.typ&amp;#34;&#xA;#import theme: slide, untracked-slide, new-section, alert, note&#xA;&#xA;#show: theme.setup.with(&#xA;  footer: [Slide template],&#xA;  text-font: &amp;#34;Libertinus Serif&amp;#34;,&#xA;  code-font: &amp;#34;DejaVu Sans Mono&amp;#34;,&#xA;  text-size: 22pt,&#xA;)&#xA;&#xA;#untracked-slide[&#xA;  #set page(header: none, footer: none)&#xA;  #set align(center &amp;#43; horizon)&#xA;  #text(size: 1.5em, weight: &amp;#34;bold&amp;#34;)[Slide template]&#xA;]&#xA;&#xA;#new-section[First section]&#xA;&#xA;#slide[&#xA;  = First slide&#xA;&#xA;  A #alert[highlight] and a speaker note.&#xA;&#xA;  #note[This note is absent from the audience PDF.]&#xA;]&#xA;&#xA;#slide[&#xA;  = Second slide&#xA;&#xA;  The navigation bar counts this slide within the first section.&#xA;]&#xA;&#xA;#new-section[Second section]&#xA;&#xA;#slide[&#xA;  = Third slide&#xA;&#xA;  The section changes, and the dot count starts again.&#xA;]&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-just&#34;&gt;watch:&#xA;    typst watch --input notes=true slides.typ&#xA;&#xA;all: build audience&#xA;&#xA;build:&#xA;    typst compile --input notes=true slides.typ&#xA;&#xA;alias speaker := build&#xA;&#xA;audience:&#xA;    typst compile --input notes=false slides.typ slides-audience.pdf&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;slides.typ&lt;/code&gt; is a minimal deck with two sections, three tracked slides, an&#xA;untracked title, and a speaker note. Change its content and fonts for a new&#xA;presentation; keep the theme and build recipes.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;Justfile&lt;/code&gt; produces two PDFs from the same source. &lt;code&gt;notes=false&lt;/code&gt; hides speaker&#xA;notes from the audience copy:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;% just all&#xA;typst compile --input notes=true slides.typ&#xA;typst compile --input notes=false slides.typ slides-audience.pdf&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: typst: reusable polylux slides with section navigation&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/meta/&#34;&gt;#meta&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>talisman: exempt git commit SHAs
      </title>
      <link>https://perrotta.dev/2026/09/talisman-exempt-git-commit-shas/</link>
      <pubDate>Mon, 28 Sep 2026 13:02:01 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>dev</category>
      <category>git</category>
      <category>security</category>
      <guid>https://perrotta.dev/2026/09/talisman-exempt-git-commit-shas/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: make &lt;a href=&#34;https://github.com/thoughtworks/talisman&#34;&gt;Talisman&lt;/a&gt;&#xA;stop reporting full Git commit SHAs as hex-encoded secrets (false positives).&lt;/p&gt;&#xA;&lt;p&gt;A typical false positive &amp;lsquo;violation&amp;rsquo; looks like the following:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% printf &amp;#39;%s\n&amp;#39; &amp;#39;675adb16677f8d3615f499f43b6ba26e04cfffa4&amp;#39; &amp;gt; talisman-commit-sha-repro.txt&#xA;% git add talisman-commit-sha-repro.txt&#xA;% prek run talisman-commit --files talisman-commit-sha-repro.txt&#xA;talisman.................................................................Failed&#xA;- hook id: talisman-commit&#xA;- exit code: 1&#xA;&#xA;Talisman Report:&#xA;&amp;#43;-------------------------------&amp;#43;------------------------------------------&amp;#43;----------&amp;#43;&#xA;|             FILE              |                  ERRORS                  | SEVERITY |&#xA;&amp;#43;-------------------------------&amp;#43;------------------------------------------&amp;#43;----------&amp;#43;&#xA;| talisman-commit-sha-repro.txt | Expected file to not contain             | high     |&#xA;|                               | hex encoded texts such as:               |          |&#xA;|                               | 675adb16677f8d3615f499f43b6ba26e04cfffa4 |          |&#xA;&amp;#43;-------------------------------&amp;#43;------------------------------------------&amp;#43;----------&amp;#43;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;To stop it once and for all, add a global &lt;a href=&#34;https://thoughtworks.github.io/talisman/docs/configuring-talisman/ignoring/&#34;&gt;allowed&#xA;pattern&lt;/a&gt;&#xA;for full lowercase Git object IDs:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;diff --git .talismanrc .talismanrc&#xA;index 68deb2d73c..2e4c8f7b95 100644&#xA;--- .talismanrc&#xA;&amp;#43;&amp;#43;&amp;#43; .talismanrc&#xA;@@ -1,14 &amp;#43;1,15 @@&#xA; # yaml-language-server: $schema=schemas/talismanrc.json&#xA;&#xA; # Docs: https://thoughtworks.github.io/talisman/docs/configuring-talisman/ignoring/&#xA; threshold: medium&#xA; allowed_patterns:&#xA;   # keep-sorted start&#xA;&amp;#43;  - &amp;#34;\\b[0-9a-f]{40}\\b&amp;#34;&#xA;   - &amp;#34;https://gist\\.github\\.com/\\w&amp;#43;/\\w&amp;#43;&amp;#34;&#xA;   - &amp;#34;https://github\\.com/[\\w.-]&amp;#43;/[\\w.-]&amp;#43;/blob/[0-9a-f]{40}&amp;#34;&#xA;   - &amp;#34;https://github\\.com/[\\w.-]&amp;#43;/[\\w.-]&amp;#43;/commit/[0-9a-f]{40}&amp;#34;&#xA;   - &amp;#34;https://github\\.com/[\\w.-]&amp;#43;/[\\w.-]&amp;#43;/tree/[0-9a-f]{40}&amp;#34;&#xA;   - &amp;#34;rev: \\w&amp;#43; # frozen:&amp;#34;&#xA;   # keep-sorted end&#xA;&#xA; fileignoreconfig:&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Test it:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% prek run talisman-commit --files talisman-commit-sha-repro.txt&#xA;talisman.................................................................Passed&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Talisman cannot prove that arbitrary hexadecimal text names a Git object. This&#xA;rule deliberately exempts every standalone, lowercase, 40-character hexadecimal&#xA;value. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: talisman: exempt git commit SHAs&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/security/&#34;&gt;#security&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>postgres: point-in-time restore to roll back a breaking upgrade
      </title>
      <link>https://perrotta.dev/2026/09/postgres-point-in-time-restore-to-roll-back-a-breaking-upgrade/</link>
      <pubDate>Thu, 24 Sep 2026 02:11:43 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>aws</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/09/postgres-point-in-time-restore-to-roll-back-a-breaking-upgrade/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: how to roll back a self-hosted app upgrade when its&#xA;database migrations have no down path?&lt;/p&gt;&#xA;&lt;p&gt;A new release of an internal tool started to enforce a license limit that&#xA;the old one did not. Pinning the old image back in GitOps was not enough:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;[ERROR] Migration failed - inconsistent migrations&#xA;Traceback (most recent call last):&#xA;  File &amp;#34;/etc/service/app/./run&amp;#34;, line 145, in &amp;lt;module&amp;gt;&#xA;    main()&#xA;subprocess.CalledProcessError: Command &amp;#39;[&amp;#39;/usr/local/bin/app&amp;#39;, &amp;#39;migrate&amp;#39;, &amp;#39;--verbosity=debug&amp;#39;]&amp;#39; returned non-zero exit status 1.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The old binary refused to start against the new schema. RDS automated&#xA;backups were on, with 30-day retention:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% aws rds describe-db-instance-automated-backups \&#xA;    --db-instance-identifier shared-db \&#xA;    --query &amp;#39;DBInstanceAutomatedBackups[].[Status,RestoreWindow.EarliestTime,RestoreWindow.LatestTime]&amp;#39; \&#xA;    --output table&#xA;----------------------------------------------------------------------&#xA;|                 DescribeDBInstanceAutomatedBackups                 |&#xA;&amp;#43;--------&amp;#43;-----------------------------&amp;#43;-----------------------------&amp;#43;&#xA;|  active|  2026-08-24T22:41:45&amp;#43;00:00  |  2026-09-23T22:41:45&amp;#43;00:00  |&#xA;&amp;#43;--------&amp;#43;-----------------------------&amp;#43;-----------------------------&amp;#43;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Several apps share that instance. An in-place restore would have rolled back&#xA;all of their databases. RDS point-in-time restore always creates a new&#xA;instance anyway:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% aws rds restore-db-instance-to-point-in-time \&#xA;    --source-db-instance-identifier shared-db \&#xA;    --target-db-instance-identifier shared-db-restore-20260910 \&#xA;    --restore-time 2026-09-10T12:00:00Z \&#xA;    --db-instance-class db.t4g.micro \&#xA;    --no-multi-az --no-publicly-accessible --no-deletion-protection&#xA;shared-db-restore-20260910&#x9;creating&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;With the app scaled to zero, a throwaway pod in the cluster moved only the&#xA;app&amp;rsquo;s database across:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% pg_dump -h shared-db-restore-20260910.xxx.rds.amazonaws.com \&#xA;    -d app -Fc -f /tmp/app.dump&#xA;% psql -h shared-db.xxx.rds.amazonaws.com -d postgres \&#xA;    -c &amp;#34;ALTER DATABASE app RENAME TO app_upgraded_bak;&amp;#34; \&#xA;    -c &amp;#34;CREATE DATABASE app OWNER app;&amp;#34;&#xA;% pg_restore -h shared-db.xxx.rds.amazonaws.com \&#xA;    -d app --no-owner --role=app -j 2 /tmp/app.dump&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Rename, not drop: rollback stays two &lt;code&gt;ALTER DATABASE&lt;/code&gt; statements away. With&#xA;the old schema back, the pinned image started cleanly:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;[INFO] Migrations to perform&#xA;[INFO] Performing migration for add-unified-comment-output-details&#xA;[INFO] Completed migration for add-unified-comment-output-details&#xA;[INFO] Migration complete&#xA;[INFO] Starting server&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The cost: everything the app wrote after the restore point was gone.&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;🤖 &lt;em&gt;Drafted with &lt;a href=&#34;https://github.com/thiagowfx/skills/blob/master/plugins/thiagowfx/skills/bloggify/SKILL.md&#34;&gt;&lt;code&gt;/bloggify&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt; ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: postgres: point-in-time restore to roll back a breaking upgrade&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/aws/&#34;&gt;#aws&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/kubernetes/&#34;&gt;#kubernetes&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>↗ Reply to: Put an AV test at the start of your slides
      </title>
      <link>https://perrotta.dev/2026/09/reply-to-put-an-av-test-at-the-start-of-your-slides/</link>
      <pubDate>Sun, 20 Sep 2026 00:13:19 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>commentary</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/09/reply-to-put-an-av-test-at-the-start-of-your-slides/</guid>
      <description>&lt;p&gt;♠ Terence Eden:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;For years, I&amp;rsquo;ve had a test-card at the start of my slides. Before I start my&#xA;talk, I can immediately see if the aspect ratio is wrong, colours are off, or&#xA;any other visual issues with the presentation.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;In fact, it&amp;rsquo;s brilliant and simple. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: Reply to: Put an AV test at the start of your slides&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/categories/commentary/&#34;&gt;%commentary&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>↗ Reply to: I don&#39;t like LLMs
      </title>
      <link>https://perrotta.dev/2026/09/reply-to-i-dont-like-llms/</link>
      <pubDate>Sun, 20 Sep 2026 00:10:37 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>commentary</category>
      <category>ai</category>
      <category>dev</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/09/reply-to-i-dont-like-llms/</guid>
      <description>&lt;p&gt;♠ Martin Fowler:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;One of my most successful life-hacks is to avoid people I don&amp;rsquo;t like or don&amp;rsquo;t&#xA;trust. I decline to interact with them socially, and make a deliberate effort&#xA;to avoid working with them too, even if they are doing much that is&#xA;beneficial. I feel that hanging out with pleasant, capable people, the people&#xA;with integrity, has made my life a far better one. Hence my visceral dislike&#xA;of interacting with an LLM that&amp;rsquo;s not just making a pretense of being human,&#xA;but also posing as the kind of human I walk away from.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;Words of wisdom. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: Reply to: I don&#39;t like LLMs&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/categories/commentary/&#34;&gt;%commentary&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>terraform: destroy a deleted project from git history
      </title>
      <link>https://perrotta.dev/2026/09/terraform-destroy-a-deleted-project-from-git-history/</link>
      <pubDate>Thu, 17 Sep 2026 14:15:09 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <category>terraform</category>
      <guid>https://perrotta.dev/2026/09/terraform-destroy-a-deleted-project-from-git-history/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: how can I run &lt;code&gt;terraform destroy&lt;/code&gt; after deleting a&#xA;project from the current Git checkout?&lt;/p&gt;&#xA;&lt;p&gt;Deleting Terraform configuration does not destroy resources from its old&#xA;state. My &lt;code&gt;just destroy&lt;/code&gt; wrapper could no longer find the project:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;% just destroy g08-opencost&#xA;Resolved path: g08-opencost&#xA;Error handling -chdir option: chdir g08-opencost: no such file or directory&#xA;error: recipe `destroy` failed with exit code 1&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The last usable configuration is the parent of the commit that deleted its&#xA;path. I taught the recipe to find and extract that revision into a temporary&#xA;directory:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;-    resolved_path=$(just _resolve_and_echo &amp;#34;{{ module_path }}&amp;#34;)&#xA;&amp;#43;    resolved_path=$(just _resolve_path &amp;#34;{{ module_path }}&amp;#34;)&#xA;&amp;#43;    historical_checkout=&amp;#34;&amp;#34;&#xA;&amp;#43;    cleanup() {&#xA;&amp;#43;        if [[ -n &amp;#34;$historical_checkout&amp;#34; ]]; then&#xA;&amp;#43;            rm -rf &amp;#34;$historical_checkout&amp;#34;&#xA;&amp;#43;        fi&#xA;&amp;#43;    }&#xA;&amp;#43;    trap cleanup EXIT&#xA;&amp;#43;&#xA;&amp;#43;    if [[ ! -d &amp;#34;$resolved_path&amp;#34; ]]; then&#xA;&amp;#43;        project_name=&amp;#34;${resolved_path%/}&amp;#34;&#xA;&amp;#43;        project_name=&amp;#34;${project_name##*/}&amp;#34;&#xA;&amp;#43;        project_path=&amp;#34;standalone/$project_name&amp;#34;&#xA;&amp;#43;        deletion_commit=$(git log -1 --format=%H --diff-filter=D -- &amp;#34;$project_path&amp;#34;)&#xA;&amp;#43;&#xA;&amp;#43;        if [[ -z &amp;#34;$deletion_commit&amp;#34; ]] || ! git cat-file -e &amp;#34;$deletion_commit^:$project_path&amp;#34; 2&amp;gt;/dev/null; then&#xA;&amp;#43;            just _error &amp;#34;Project not found in current checkout or Git history: {{ module_path }}&amp;#34;&#xA;&amp;#43;            exit 1&#xA;&amp;#43;        fi&#xA;&amp;#43;&#xA;&amp;#43;        historical_commit=$(git rev-parse &amp;#34;$deletion_commit^&amp;#34;)&#xA;&amp;#43;        historical_checkout=$(mktemp -d)&#xA;&amp;#43;        git archive &amp;#34;$historical_commit&amp;#34; | tar -x -C &amp;#34;$historical_checkout&amp;#34;&#xA;&amp;#43;        resolved_path=&amp;#34;$historical_checkout/$project_path&amp;#34;&#xA;&amp;#43;        just _info &amp;#34;Project was deleted. Using $project_path from commit ${historical_commit:0:12}.&amp;#34;&#xA;&amp;#43;    fi&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The normal backend initialization and destroy path can then run unchanged:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;% just destroy g08-opencost&#xA;Project was deleted. Using standalone/g08-opencost from commit fb82317c0010.&#xA;Resolved path: /var/folders/yr/6sw3yylx6gjcy5jr38d6j6000000gn/T/tmp.0als997N54/standalone/g08-opencost&#xA;...&#xA;Plan: 0 to add, 0 to change, 8 to destroy.&#xA;&#xA;Do you really want to destroy all resources?&#xA;  Terraform will destroy all your managed infrastructure, as shown above.&#xA;  There is no undo. Only &amp;#39;yes&amp;#39; will be accepted to confirm.&#xA;&#xA;  Enter a value:&#xA;Error: error asking for approval: EOF&#xA;&#xA;Releasing state lock. This may take a few moments...&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;No automatic approval: the historical configuration only gets Terraform back&#xA;to its usual confirmation prompt. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: terraform: destroy a deleted project from git history&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/terraform/&#34;&gt;#terraform&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>@claude: troubleshoot
      </title>
      <link>https://perrotta.dev/2026/09/@claude-troubleshoot/</link>
      <pubDate>Wed, 16 Sep 2026 17:41:40 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>dev</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/09/@claude-troubleshoot/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://www.anthropic.com/news/introducing-claude-tag&#34;&gt;Claude Tag&lt;/a&gt;:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;Claude Tag is a new way for teams to work with Claude.&lt;/p&gt;&#xA;&lt;p&gt;We&amp;rsquo;re starting on Slack, which Claude can join as a team member. Grant Claude&#xA;access to selected channels, and connect it to whichever tools, data—and even&#xA;codebases—you choose. Then, anyone in the channel can tag @Claude in, and&#xA;delegate tasks to it while they focus on other work. Claude builds context by&#xA;remembering relevant information from the channels it&amp;rsquo;s in, and can plan out&#xA;tasks to complete in the future.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;Reply &lt;code&gt;@claude: troubleshoot&lt;/code&gt; in any Slack thread to address most issues and&#xA;teammate support questions.&lt;/p&gt;&#xA;&lt;p&gt;The &lt;em&gt;Sonnet&lt;/em&gt; model is good enough to power it.&lt;/p&gt;&#xA;&lt;p&gt;Why do I (still) have a job anyway?! ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: @claude: troubleshoot&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>lf: edit files and enter directories with enter
      </title>
      <link>https://perrotta.dev/2026/09/lf-edit-files-and-enter-directories-with-enter/</link>
      <pubDate>Wed, 16 Sep 2026 16:41:39 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>linux</category>
      <guid>https://perrotta.dev/2026/09/lf-edit-files-and-enter-directories-with-enter/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: in &lt;a href=&#34;https://github.com/gokcehan/lf&#34;&gt;lf&lt;/a&gt;, how can&#xA;&lt;code&gt;&amp;lt;Enter&amp;gt;&lt;/code&gt; edit files without trying to edit directories?&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;e&lt;/code&gt; already opens the selected file in &lt;code&gt;$EDITOR&lt;/code&gt;. My first keybinding mapping&#xA;attempt copied its default command:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt; # Edit file: &amp;#39;e&amp;#39; (mapped out-of-the-box)&#xA;&amp;#43;map &amp;lt;enter&amp;gt; $$EDITOR &amp;#34;$f&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;This worked for files, but it also sent directories to the editor, which is&#xA;unwelcome.&lt;/p&gt;&#xA;&lt;p&gt;Next up: lf&amp;rsquo;s built-in &lt;code&gt;open&lt;/code&gt; command already has the split behavior I wanted&#xA;for directories:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;% lf -doc | sed -n &amp;#39;533,540p&amp;#39;&#xA;Change the current working directory to the parent directory.&#xA;&#xA;open (default l and &amp;lt;right&amp;gt;)&#xA;&#xA;If the current file is a directory, then change the current directory to&#xA;it, otherwise, execute the open command. A default open command is&#xA;provided to call the default system opener asynchronously with the&#xA;current file as the argument. A custom open command can be defined to&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;So I kept the file branch in &lt;code&gt;$EDITOR&lt;/code&gt; and sent the directory branch back to&#xA;lf&amp;rsquo;s &lt;code&gt;open&lt;/code&gt; command:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;cmd edit-or-open ${{&#xA;    if [ -d &amp;#34;$f&amp;#34; ]; then&#xA;        &amp;#34;$lf&amp;#34; -remote &amp;#34;send $id open&amp;#34;&#xA;    else&#xA;        $EDITOR &amp;#34;$f&amp;#34;&#xA;    fi&#xA;}}&#xA;map &amp;lt;enter&amp;gt; edit-or-open&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;This works exactly as intended!&lt;/p&gt;&#xA;&lt;p&gt;The complete &lt;a href=&#34;https://github.com/thiagowfx/.dotfiles/blob/3ded1cb73a5ef401aae7af0dd170a9e647c5f5ce/lf/.config/lf/lfrc#L14-L21&#34;&gt;lf&#xA;configuration&lt;/a&gt;&#xA;lives in my dotfiles. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: lf: edit files and enter directories with enter&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/linux/&#34;&gt;#linux&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pi: stop repeated macos keychain prompts for mcp oauth
      </title>
      <link>https://perrotta.dev/2026/09/pi-stop-repeated-macos-keychain-prompts-for-mcp-oauth/</link>
      <pubDate>Tue, 08 Sep 2026 13:09:35 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>macos</category>
      <category>pi</category>
      <category>security</category>
      <guid>https://perrotta.dev/2026/09/pi-stop-repeated-macos-keychain-prompts-for-mcp-oauth/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: why did Pi keep asking for my macOS login keychain&#xA;password during MCP OAuth authentication?&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;/mcp-auth grafana&lt;/code&gt; opened the same prompt again after I entered the correct&#xA;password and selected &lt;strong&gt;Allow&lt;/strong&gt;:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;pi wants to use your confidential information stored in&#xA;&amp;ldquo;pi-mcp-adapter.oauth&amp;rdquo; in your keychain.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://github.com/nicobailon/pi-mcp-adapter&#34;&gt;&lt;code&gt;pi-mcp-adapter&lt;/code&gt;&lt;/a&gt; stores OAuth&#xA;credentials in the operating system credential store. Its Grafana record name&#xA;is a SHA-256 hash:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% node -e &amp;#39;const {createHash}=require(&amp;#34;crypto&amp;#34;); console.log(&amp;#34;sha256-&amp;#34; &amp;#43; createHash(&amp;#34;sha256&amp;#34;).update(&amp;#34;grafana&amp;#34;).digest(&amp;#34;hex&amp;#34;))&amp;#39;&#xA;sha256-cace491b69555e8d0f77747d47ae54e31ce4cc322fe51a7bdcf64402f3676ebf&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The Keychain ACL still allowed old Homebrew Node executables:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% security dump-keychain -a | rg -A18 &amp;#39;sha256-cace491b69555e8d0f77747d47ae54e31ce4cc322fe51a7bdcf64402f3676ebf&amp;#34;&amp;#39;&#xA;    entry 1:&#xA;        authorizations (6): decrypt derive export_clear export_wrapped mac sign&#xA;        don&amp;#39;t-require-password&#xA;        description: pi-mcp-adapter.oauth&#xA;        applications (2):&#xA;            0: /opt/homebrew/Cellar/node/26.6.0/bin/node (status -67068)&#xA;                requirement: cdhash H&amp;#34;ea8d578543c9d8c21b74e26f7976cc30b60e24a5&amp;#34;&#xA;            1: /opt/homebrew/Cellar/node/26.5.0_1/bin/node (status -67068)&#xA;                requirement: cdhash H&amp;#34;4947cbedfdb054cda3708de8c0b22946c4fd98f1&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Pi now used Homebrew Node 26.8.1 with a different ad-hoc signature:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% codesign -dv --verbose=4 /opt/homebrew/Cellar/node/26.8.1/bin/node 2&amp;gt;&amp;amp;1 | rg &amp;#39;Identifier|CDHash|TeamIdentifier&amp;#39;&#xA;Identifier=node-55554944b8d19d4157093cf1805ecde9a77d4850&#xA;CandidateCDHash sha256=cead962814af248488bb6597ee22a32fc23d00a1&#xA;CandidateCDHashFull sha256=cead962814af248488bb6597ee22a32fc23d00a1aac036bb431b750dd5120163&#xA;CDHash=cead962814af248488bb6597ee22a32fc23d00a1&#xA;TeamIdentifier=not set&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The password was valid. macOS did not trust this new executable to read the&#xA;old item. Selecting &lt;strong&gt;Allow&lt;/strong&gt; authorized one access, so the next read prompted&#xA;again.&lt;/p&gt;&#xA;&lt;p&gt;I removed the base record and its two credential chunks, then authenticated&#xA;again:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% service=pi-mcp-adapter.oauth&#xA;% base=sha256-cace491b69555e8d0f77747d47ae54e31ce4cc322fe51a7bdcf64402f3676ebf&#xA;% security dump-keychain 2&amp;gt;/dev/null \&#xA;    | sed -n &amp;#39;s/^[[:space:]]*&amp;#34;acct&amp;#34;&amp;lt;blob&amp;gt;=&amp;#34;\([^&amp;#34;]*\)&amp;#34;/\1/p&amp;#39; \&#xA;    | rg &amp;#34;^${base}(\.chunk\.[a-f0-9]{16}\.[0-9]&amp;#43;)?$&amp;#34; \&#xA;    | while read -r account; do&#xA;        security delete-generic-password -s &amp;#34;$service&amp;#34; -a &amp;#34;$account&amp;#34;&#xA;      done&#xA;password has been deleted.&#xA;password has been deleted.&#xA;password has been deleted.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A future Homebrew Node update would change its code hash again. The official&#xA;Node binary installed by Mise has a stable Developer ID requirement instead:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% codesign -dr - ~/.local/share/mise/installs/node/latest/bin/node 2&amp;gt;&amp;amp;1&#xA;designated =&amp;gt; identifier node and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = HX7739G8FX&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Installing Pi through that Node avoids tying Keychain access to one Homebrew&#xA;binary hash:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% npm install -g --ignore-scripts @earendil-works/pi-coding-agent&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: pi: stop repeated macos keychain prompts for mcp oauth&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/macos/&#34;&gt;#macos&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pi/&#34;&gt;#pi&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/security/&#34;&gt;#security&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pi: approve several pull requests at once
      </title>
      <link>https://perrotta.dev/2026/09/pi-approve-several-pull-requests-at-once/</link>
      <pubDate>Tue, 08 Sep 2026 12:15:25 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <category>pi</category>
      <guid>https://perrotta.dev/2026/09/pi-approve-several-pull-requests-at-once/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/02/github-approve-prs-from-cli/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: how can I approve several reviewed pull requests without&#xA;opening each one individually?&lt;/p&gt;&#xA;&lt;p&gt;I pasted six pull request URLs into &lt;a href=&#34;https://pi.dev/&#34;&gt;Pi&lt;/a&gt; and asked it to&#xA;approve all of them. It ran the same &lt;code&gt;gh&lt;/code&gt; command for each URL:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;prs=(&#xA;  https://github.com/[redacted]/[redacted]/pull/7692&#xA;  https://github.com/[redacted]/[redacted]/pull/7695&#xA;  https://github.com/[redacted]/[redacted]/pull/7689&#xA;  https://github.com/[redacted]/[redacted]/pull/7694&#xA;  https://github.com/[redacted]/[redacted]/pull/7693&#xA;  https://github.com/[redacted]/[redacted]/pull/36196&#xA;)&#xA;status=0&#xA;for pr in &amp;#34;${prs[@]}&amp;#34;; do&#xA;  if gh pr review --approve &amp;#34;$pr&amp;#34;; then&#xA;    printf &amp;#39;APPROVED %s\n&amp;#39; &amp;#34;$pr&amp;#34;&#xA;  else&#xA;    printf &amp;#39;FAILED %s\n&amp;#39; &amp;#34;$pr&amp;#34; &amp;gt;&amp;amp;2&#xA;    status=1&#xA;  fi&#xA;done&#xA;exit &amp;#34;$status&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;APPROVED https://github.com/[redacted]/[redacted]/pull/7692&#xA;APPROVED https://github.com/[redacted]/[redacted]/pull/7695&#xA;APPROVED https://github.com/[redacted]/[redacted]/pull/7689&#xA;APPROVED https://github.com/[redacted]/[redacted]/pull/7694&#xA;APPROVED https://github.com/[redacted]/[redacted]/pull/7693&#xA;APPROVED https://github.com/[redacted]/[redacted]/pull/36196&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;One prompt, six approvals, no repository checkouts. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: pi: approve several pull requests at once&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pi/&#34;&gt;#pi&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>espanso: restart a stale daemon after upgrading
      </title>
      <link>https://perrotta.dev/2026/09/espanso-restart-a-stale-daemon-after-upgrading/</link>
      <pubDate>Mon, 07 Sep 2026 15:03:27 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>macos</category>
      <guid>https://perrotta.dev/2026/09/espanso-restart-a-stale-daemon-after-upgrading/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: why did Espanso stop expanding text while its service was&#xA;running?&lt;/p&gt;&#xA;&lt;p&gt;The CLI reported a healthy service on version 2.4.1:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% espanso --version&#xA;2.4.1&#xA;% espanso status&#xA;espanso is running&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The daemon log disagreed:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% rg &amp;#39;espanso version:&amp;#39; ~/Library/Caches/espanso/espanso.log | tail -1&#xA;16:50:24 [daemon(2251)] [INFO] espanso version: 2.4.0&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The app had been upgraded on September 3, but the launcher and daemon still&#xA;came from August 24. Restarting the worker had not replaced either one:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ps -p 1899,2251,92631 -o pid,lstart,command&#xA;  PID STARTED                      COMMAND&#xA; 1899 Mon Aug 24 16:50:17 2026     /Applications/Espanso.app/Contents/MacOS/espanso launcher&#xA; 2251 Mon Aug 24 16:50:23 2026     /Applications/Espanso.app/Contents/MacOS/espanso daemon&#xA;92631 Mon Sep  7 14:55:26 2026     /Applications/Espanso.app/Contents/MacOS/espanso worker --monitor-daemon --start-reason manual_restart&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A full service restart replaced all three processes:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% espanso service restart&#xA;% ps -p &amp;#34;$(pgrep -d, -f &amp;#39;/Applications/Espanso.app/Contents/MacOS/espanso (launcher|daemon|worker)&amp;#39;)&amp;#34; -o pid,lstart,command&#xA;  PID STARTED                      COMMAND&#xA; 3049 Mon Sep  7 15:01:47 2026     /Applications/Espanso.app/Contents/MacOS/espanso launcher&#xA; 3063 Mon Sep  7 15:01:47 2026     /Applications/Espanso.app/Contents/MacOS/espanso daemon&#xA; 3064 Mon Sep  7 15:01:47 2026     /Applications/Espanso.app/Contents/MacOS/espanso worker --monitor-daemon&#xA;% rg &amp;#39;espanso version:&amp;#39; ~/Library/Caches/espanso/espanso.log | tail -1&#xA;15:01:47 [daemon(3063)] [INFO] espanso version: 2.4.1&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Text expansion worked again.&lt;/p&gt;&#xA;&lt;p&gt;So now I&amp;rsquo;ll need to remember to restart &lt;code&gt;espanso&lt;/code&gt; whenever it is upgraded. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: espanso: restart a stale daemon after upgrading&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/macos/&#34;&gt;#macos&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>Gen AI is the future
      </title>
      <link>https://perrotta.dev/2026/09/gen-ai-is-the-future/</link>
      <pubDate>Mon, 07 Sep 2026 09:56:38 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/09/gen-ai-is-the-future/</guid>
      <description>&lt;p&gt;♠ Just an ordinary day with OpenAI inference via &lt;a href=&#34;https://pi.dev&#34;&gt;&lt;code&gt;pi&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;Error: Codex error: Our servers are currently overloaded. Please try again&#xA;later.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: Gen AI is the future&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pi: git-ai: trace code commits back to agent sessions
      </title>
      <link>https://perrotta.dev/2026/09/pi-git-ai-trace-code-commits-back-to-agent-sessions/</link>
      <pubDate>Mon, 07 Sep 2026 09:26:59 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>dev</category>
      <category>git</category>
      <category>pi</category>
      <guid>https://perrotta.dev/2026/09/pi-git-ai-trace-code-commits-back-to-agent-sessions/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://github.com/git-ai-project/git-ai&#34;&gt;Git AI&lt;/a&gt; records agent checkpoints in&#xA;&lt;a href=&#34;https://git-scm.com/docs/git-notes&#34;&gt;Git notes&lt;/a&gt;&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;https://perrotta.dev/2026/09/pi-git-ai-trace-code-commits-back-to-agent-sessions/#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;, without changing commit&#xA;messages or repository files.&lt;/p&gt;&#xA;&lt;p&gt;In order to integrate it with &lt;a href=&#34;https://pi.dev/&#34;&gt;Pi&lt;/a&gt; we use an extension,&#xA;because Git AI must observe file and Bash tool calls:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typescript&#34;&gt;const GIT_AI_BIN = join(homedir(), &amp;#39;.git-ai&amp;#39;, &amp;#39;bin&amp;#39;, &amp;#39;git-ai&amp;#39;);&#xA;&#xA;const child = spawn(GIT_AI_BIN, [&amp;#39;checkpoint&amp;#39;, &amp;#39;pi&amp;#39;, &amp;#39;--hook-input&amp;#39;, &amp;#39;stdin&amp;#39;], {&#xA;  stdio: [&amp;#39;pipe&amp;#39;, &amp;#39;ignore&amp;#39;, &amp;#39;ignore&amp;#39;],&#xA;});&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The extension listens before and after each mutating call. It sends Pi session&#xA;ID, model, tool, working directory, and touched files to &lt;code&gt;git-ai checkpoint&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;In a shell script in my dotfiles, I configured &lt;code&gt;git-ai&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;# Git AI daemon receives Git Trace2 events through per-user socket.&#xA;if command -v git-ai &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then&#xA;  export GIT_TRACE2_EVENT=&amp;#34;af_unix:stream:${GIT_AI_DAEMON_TRACE_SOCKET:-$HOME/.git-ai/internal/daemon/trace2.sock}&amp;#34;&#xA;  export GIT_TRACE2_EVENT_NESTING=0&#xA;fi&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;After restarting Pi, code attribution starts to appear alongside normal &lt;code&gt;git blame&lt;/code&gt; calls&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a href=&#34;https://perrotta.dev/2026/09/pi-git-ai-trace-code-commits-back-to-agent-sessions/#fn:2&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git ai blame src/main.rs&#xA;442dfde2 (pi 2026-09-05 14:29:59 &amp;#43;0200 1) fn main() { println!(&amp;#34;pi&amp;#34;); }&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The commit note contains the useful attribution / provenance snippet:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;&amp;#34;sessions&amp;#34;: {&#xA;  &amp;#34;s_82565459b433c5&amp;#34;: {&#xA;    &amp;#34;agent_id&amp;#34;: {&#xA;      &amp;#34;tool&amp;#34;: &amp;#34;pi&amp;#34;,&#xA;      &amp;#34;id&amp;#34;: &amp;#34;pi-extension-test&amp;#34;,&#xA;      &amp;#34;model&amp;#34;: &amp;#34;gpt-5.6-sol&amp;#34;&#xA;    }&#xA;  }&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Now a suspicious line can lead from Git history to the exact Pi session. The&#xA;session ID can then subsequently be searched with &lt;a href=&#34;https://perrotta.dev/2026/09/agentsview-vs-fast-resume/&#34;&gt;&lt;code&gt;fr&lt;/code&gt;&lt;/a&gt; or resumed in Pi.&lt;/p&gt;&#xA;&lt;p&gt;Other useful commands with real examples:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git ai show HEAD&#xA;STYLE.md&#xA;  s_f2908643b7ce0d::t_739223c8b9befd 12,29,48&#xA;---&#xA;{&#xA;  &amp;#34;schema_version&amp;#34;: &amp;#34;authorship/3.0.0&amp;#34;,&#xA;  &amp;#34;git_ai_version&amp;#34;: &amp;#34;1.7.2&amp;#34;,&#xA;  &amp;#34;base_commit_sha&amp;#34;: &amp;#34;d225f5e635bbf5494574591580af8abf93da9480&amp;#34;,&#xA;  &amp;#34;prompts&amp;#34;: {},&#xA;  &amp;#34;sessions&amp;#34;: {&#xA;    &amp;#34;s_f2908643b7ce0d&amp;#34;: {&#xA;      &amp;#34;agent_id&amp;#34;: {&#xA;        &amp;#34;tool&amp;#34;: &amp;#34;pi&amp;#34;,&#xA;        &amp;#34;id&amp;#34;: &amp;#34;01a07acd-e9c7-759f-84b4-107f9ebc2556&amp;#34;,&#xA;        &amp;#34;model&amp;#34;: &amp;#34;gpt-5.6-sol&amp;#34;&#xA;      },&#xA;      &amp;#34;human_author&amp;#34;: &amp;#34;Thiago Perrotta &amp;lt;{redacted_email}&amp;gt;&amp;#34;&#xA;    }&#xA;  }&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git log --show-notes=ai&#xA;commit d225f5e635bbf5494574591580af8abf93da9480 (HEAD -&amp;gt; master)&#xA;Author: Thiago Perrotta &amp;lt;{redacted_email}&amp;gt;&#xA;Date:   Mon Sep 7 09:40:01 2026 &amp;#43;0200&#xA;&#xA;    docs: require problem statement questions&#xA;&#xA;Notes (ai):&#xA;    STYLE.md&#xA;      s_f2908643b7ce0d::t_739223c8b9befd 12,29,48&#xA;    ---&#xA;    {&#xA;      &amp;#34;schema_version&amp;#34;: &amp;#34;authorship/3.0.0&amp;#34;,&#xA;      &amp;#34;git_ai_version&amp;#34;: &amp;#34;1.7.2&amp;#34;,&#xA;      &amp;#34;base_commit_sha&amp;#34;: &amp;#34;d225f5e635bbf5494574591580af8abf93da9480&amp;#34;,&#xA;      &amp;#34;prompts&amp;#34;: {},&#xA;      &amp;#34;sessions&amp;#34;: {&#xA;        &amp;#34;s_f2908643b7ce0d&amp;#34;: {&#xA;          &amp;#34;agent_id&amp;#34;: {&#xA;            &amp;#34;tool&amp;#34;: &amp;#34;pi&amp;#34;,&#xA;            &amp;#34;id&amp;#34;: &amp;#34;01a07acd-e9c7-759f-84b4-107f9ebc2556&amp;#34;,&#xA;            &amp;#34;model&amp;#34;: &amp;#34;gpt-5.6-sol&amp;#34;&#xA;          },&#xA;          &amp;#34;human_author&amp;#34;: &amp;#34;Thiago Perrotta &amp;lt;{redacted_email}&amp;gt;&amp;#34;&#xA;        }&#xA;      }&#xA;    }&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;&#xA;&lt;hr&gt;&#xA;&lt;ol&gt;&#xA;&lt;li id=&#34;fn:1&#34;&gt;&#xA;&lt;p&gt;A little known &lt;code&gt;git&lt;/code&gt; feature.&amp;#160;&lt;a href=&#34;https://perrotta.dev/2026/09/pi-git-ai-trace-code-commits-back-to-agent-sessions/#fnref:1&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;li id=&#34;fn:2&#34;&gt;&#xA;&lt;p&gt;Of course, the LLM decided to choose a Rust example to test the&#xA;integration. &lt;em&gt;Of course&lt;/em&gt;. It certainly wouldn&amp;rsquo;t have been &lt;em&gt;my&lt;/em&gt; first choice.&amp;#160;&lt;a href=&#34;https://perrotta.dev/2026/09/pi-git-ai-trace-code-commits-back-to-agent-sessions/#fnref:2&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: pi: git-ai: trace code commits back to agent sessions&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pi/&#34;&gt;#pi&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>typst: color-coded choir parts
      </title>
      <link>https://perrotta.dev/2026/09/typst-color-coded-choir-parts/</link>
      <pubDate>Thu, 03 Sep 2026 16:54:39 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <guid>https://perrotta.dev/2026/09/typst-color-coded-choir-parts/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/02/making-a-slides-presentation-in-2026/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: a plain lyrics sheet does not say who sings what. In a&#xA;choir rehearsal one has to annotate every line by hand: sopranos here, everybody&#xA;on the refrain.&lt;/p&gt;&#xA;&lt;p&gt;Two files, one helper, no music engraving:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ls&#xA;AGENTS.md  main.pdf  main.typ  template.typ&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;template.typ&lt;/code&gt; holds the styling only — page, font, headings:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typst&#34;&gt;#let template(doc) = {&#xA;  set page(paper: &amp;#34;a4&amp;#34;, margin: (left: 2cm, right: 2cm, top: 2cm, bottom: 2cm))&#xA;  set text(font: &amp;#34;Libertinus Serif&amp;#34;, size: 11pt, lang: &amp;#34;en&amp;#34;)&#xA;  set heading(numbering: none)&#xA;&#xA;  show heading.where(level: 1): it =&amp;gt; {&#xA;    set text(size: 24pt, weight: &amp;#34;bold&amp;#34;)&#xA;    set align(center)&#xA;    it&#xA;    v(0.5em)&#xA;  }&#xA;&#xA;  doc&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;main.typ&lt;/code&gt; holds the content, plus a &lt;code&gt;parts_line()&lt;/code&gt; that draws one colored&#xA;square per voice above the lyrics:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typst&#34;&gt;#let parts_line(parts_colors, lyrics) = {&#xA;  let squares = parts_colors.map(((part, color)) =&amp;gt; {&#xA;    box(width: 1em, height: 1em, fill: color, stroke: rgb(&amp;#34;#000&amp;#34;), inset: 0pt)&#xA;  }).join(h(0.2em))&#xA;&#xA;  set align(left)&#xA;  [#squares \ #lyrics]&#xA;}&#xA;&#xA;#let soprano = rgb(&amp;#34;#ffc0cb&amp;#34;)&#xA;#let alto = rgb(&amp;#34;#90ee90&amp;#34;)&#xA;#let tenor = rgb(&amp;#34;#ffffff&amp;#34;)&#xA;#let bass = rgb(&amp;#34;#87ceeb&amp;#34;)&#xA;#let all = rgb(&amp;#34;#f0f0f0&amp;#34;)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The verse then reads almost like the score:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typst&#34;&gt;== Verse 1&#xA;&#xA;#parts_line(((&amp;#34;S&amp;#34;, soprano), (&amp;#34;B&amp;#34;, bass)), &amp;#34;Oh the weather outside is frightful,&amp;#34;)&#xA;#parts_line(((&amp;#34;all&amp;#34;, all),), &amp;#34;And since we&amp;#39;ve no place to go.&amp;#34;)&#xA;#parts_line(((&amp;#34;S&amp;#34;, soprano), (&amp;#34;A&amp;#34;, alto), (&amp;#34;T&amp;#34;, tenor), (&amp;#34;B&amp;#34;, bass)), &amp;#34;Let It Snow! Let It Snow! Let It Snow!&amp;#34;)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Two pages, a sixth of a second:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% typst --version&#xA;typst 0.15.1 (unknown commit)&#xA;&#xA;% time typst compile main.typ&#xA;typst compile main.typ  0.04s user 0.07s system 63% cpu 0.169 total&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Tenor is white and only visible because of the black stroke, which is a happy&#xA;accident: on a photocopy it still reads as a distinct part. The &lt;code&gt;&amp;quot;S&amp;quot;&lt;/code&gt; and &lt;code&gt;&amp;quot;B&amp;quot;&lt;/code&gt;&#xA;strings are ignored by the helper — they are there so the source stays readable&#xA;without counting colors. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: typst: color-coded choir parts&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pre-commit: internal hook repos, take two
      </title>
      <link>https://perrotta.dev/2026/09/pre-commit-internal-hook-repos-take-two/</link>
      <pubDate>Thu, 03 Sep 2026 14:03:03 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <category>pre-commit</category>
      <category>ssh</category>
      <guid>https://perrotta.dev/2026/09/pre-commit-internal-hook-repos-take-two/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/06/pre-commit-authentication-in-internal-github-repos/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: an internal hook repo has to be cloned by CI &lt;em&gt;and&lt;/em&gt; by&#xA;every developer, and the credentials each side owns are not the same.&lt;/p&gt;&#xA;&lt;p&gt;A &lt;code&gt;.pre-commit-config.yaml&lt;/code&gt; refers to hooks by repository:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;repos:&#xA;  - repo: https://github.com/&amp;lt;org&amp;gt;/pre-commit-hooks&#xA;    rev: 528e165cff8a11bc4f9f49a9b25f3249e09e4237 # frozen: v0.0.22&#xA;    hooks:&#xA;      - id: just-format&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Before every run, &lt;code&gt;prek&lt;/code&gt; clones each of those repositories into its cache. A&#xA;public repo clones anonymously and nobody thinks about it again. Ours moved out&#xA;of a personal account into an org-owned repo, which is &lt;code&gt;INTERNAL&lt;/code&gt; — so the&#xA;clone now needs credentials, from whoever is running.&lt;/p&gt;&#xA;&lt;p&gt;In June I gave the runners a short-lived app token and called it done. CI went&#xA;green. The next morning, a coworker could no longer commit:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;$ prek run -a&#xA;error: Failed to init hooks&#xA;  caused by: Failed to initialize repo `https://github.com/&amp;lt;org&amp;gt;/pre-commit-hooks`&#xA;  caused by: Command `git full clone` exited with an error:&#xA;&#xA;[status]&#xA;exit status: 128&#xA;&#xA;[stderr]&#xA;fatal: could not read Username for &amp;#39;https://github.com&amp;#39;: terminal prompts disabled&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Not a permissions problem: the same coworker could open and clone the repo by&#xA;hand. &lt;code&gt;prek&lt;/code&gt; clones non-interactively, and an internal repo over &lt;code&gt;https://&lt;/code&gt;&#xA;needs a credential helper to answer. Mine had a token cached; theirs did not:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git -c credential.helper= clone https://github.com/&amp;lt;org&amp;gt;/pre-commit-hooks&#xA;Cloning into &amp;#39;pre-commit-hooks&amp;#39;...&#xA;fatal: could not read Username for &amp;#39;https://github.com&amp;#39;: terminal prompts disabled&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;SKIP=&lt;/code&gt; does not rescue this. The clone happens while hooks are being&#xA;initialized, long before &lt;code&gt;SKIP&lt;/code&gt; is consulted, so the whole run dies and commits&#xA;with it. Three days after the migration, I reverted it everywhere:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;4f74a46a Revert: ci: migrate pre-commit hooks to &amp;lt;org&amp;gt;/pre-commit-hooks&#xA;ea747ebe Revert: ci: migrate pre-commit hooks to &amp;lt;org&amp;gt;/pre-commit-hooks&#xA;4ec7e8063 Revert: ci: migrate pre-commit hooks to &amp;lt;org&amp;gt;/pre-commit-hooks&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That June token was handed to git as a URL rewrite: &lt;code&gt;https://github.com/&lt;/code&gt;&#xA;became &lt;code&gt;https://x-access-token:$TOKEN@github.com/&lt;/code&gt; on the runner. I had also&#xA;considered &lt;code&gt;git@github.com:&amp;lt;org&amp;gt;/pre-commit-hooks&lt;/code&gt; back then, and dropped it&#xA;because runners have no SSH key. The premise was right and the conclusion was&#xA;backwards — a rewrite runs in whichever direction we point it, and I had pointed&#xA;it at the side that could not adapt.&lt;/p&gt;&#xA;&lt;p&gt;So the config takes the URL that developers already have credentials for:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;-  - repo: https://github.com/&amp;lt;org&amp;gt;/pre-commit-hooks&#xA;-    rev: 528e165cff8a11bc4f9f49a9b25f3249e09e4237 # frozen: v0.0.22&#xA;&amp;#43;  - repo: git@github.com:&amp;lt;org&amp;gt;/pre-commit-hooks&#xA;&amp;#43;    rev: 3030e9389488a2e4543a4d5d45d78a63bb591864 # frozen: v1.1.0&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;And the keyless runner rewrites that SSH prefix back to HTTPS, with the same&#xA;short-lived app token as before:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;- name: Authenticate git for the internal hooks repo&#xA;  env:&#xA;    TOKEN: ${{ steps.generate-token.outputs.token }}&#xA;  run: git config --global url.&amp;#34;https://x-access-token:${TOKEN}@github.com/&amp;lt;org&amp;gt;/&amp;#34;.insteadOf &amp;#34;git@github.com:&amp;lt;org&amp;gt;/&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A warm cache is what hid the bug the first time, so both paths were verified&#xA;against a cold one. The developer path, cloning over SSH:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% XDG_CACHE_HOME=$(mktemp -d) prek run -a just-format&#xA;Format Justfiles.........................................................Passed&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The runner path, with SSH made unusable and nothing but the rewrite in &lt;code&gt;$HOME&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% export HOME=$(mktemp -d)&#xA;% printf &amp;#39;[url &amp;#34;https://x-access-token:%s@github.com/&amp;lt;org&amp;gt;/&amp;#34;]\n\tinsteadOf = git@github.com:&amp;lt;org&amp;gt;/\n&amp;#39; &amp;#34;$(gh auth token)&amp;#34; &amp;gt; &amp;#34;$HOME/.gitconfig&amp;#34;&#xA;% SSH_AUTH_SOCK= GIT_SSH_COMMAND=/bin/false XDG_CACHE_HOME=&amp;#34;$HOME/.cache&amp;#34; prek run -a just-format&#xA;Format Justfiles.........................................................Passed&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That second run also exposed where the rewrite belongs. Asking for a single hook&#xA;populated the cache with every hook repo in the config:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ls &amp;#34;$HOME/.cache/prek/repos&amp;#34; | wc -l&#xA;      13&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;prek&lt;/code&gt; initializes all of them up front, so the rewrite goes in &lt;em&gt;every&lt;/em&gt; job that&#xA;runs &lt;code&gt;prek&lt;/code&gt; — not only the linting one. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: pre-commit: internal hook repos, take two&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pre-commit/&#34;&gt;#pre-commit&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/ssh/&#34;&gt;#ssh&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>starship: hyperlink github PR number
      </title>
      <link>https://perrotta.dev/2026/09/starship-hyperlink-github-pr-number/</link>
      <pubDate>Thu, 03 Sep 2026 14:02:52 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <guid>https://perrotta.dev/2026/09/starship-hyperlink-github-pr-number/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/01/starship-github-pr-prompt/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: the GitHub PR number in my Starship prompt was plain&#xA;text.&lt;/p&gt;&#xA;&lt;p&gt;The custom module already asked &lt;code&gt;gh&lt;/code&gt; for the PR number and cached it with&#xA;&lt;a href=&#34;https://perrotta.dev/2024/12/bkt-cache-command-outputs/&#34;&gt;&lt;code&gt;bkt&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-toml&#34;&gt;[custom.github_pr]&#xA;command = &amp;#34;bkt --ttl=10m --scope=\&amp;#34;$(git rev-parse --show-toplevel):$(git branch --show-current)\&amp;#34; -- gh pr view --json number --jq &amp;#39;\&amp;#34;#\&amp;#34; &amp;#43; (.number | tostring)&amp;#39; 2&amp;gt;/dev/null&amp;#34;&#xA;when = &amp;#34;git rev-parse --is-inside-work-tree 2&amp;gt;/dev/null&amp;#34;&#xA;format = &amp;#34; [$output](magenta)&amp;#34;&#xA;ignore_timeout = true&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;An OSC 8 escape sequence can turn &lt;code&gt;#78&lt;/code&gt; into a terminal hyperlink. I first used&#xA;&lt;code&gt;ESC \&lt;/code&gt; to terminate each sequence. The template produced the expected bytes,&#xA;but zsh prompt expansion consumed each backslash and left an incomplete escape&#xA;sequence:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;missing hyperlink sequence; output=b&amp;#39;...\x1b]8;;https://github.com/thiagowfx/.dotfiles/pull/78\x1b#78\x1b]8;;\x1b...&amp;#39;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;BEL terminates OSC 8 without passing a backslash through zsh:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;diff --git starship/.config/starship.toml starship/.config/starship.toml&#xA;index 87e83794..739ee62f 100644&#xA;--- starship/.config/starship.toml&#xA;&amp;#43;&amp;#43;&amp;#43; starship/.config/starship.toml&#xA;@@ -54,7 &amp;#43;54,7 @@ format = &amp;#34; [⎇ $output]($style)&amp;#34;&#xA; style = &amp;#34;bold yellow&amp;#34;&#xA;&#xA; [custom.github_pr]&#xA;-command = &amp;#34;bkt --ttl=10m --scope=\&amp;#34;$(git rev-parse --show-toplevel):$(git branch --show-current)\&amp;#34; -- gh pr view --json number --jq &amp;#39;\&amp;#34;#\&amp;#34; &amp;#43; (.number | tostring)&amp;#39; 2&amp;gt;/dev/null&amp;#34;&#xA;&amp;#43;command = &amp;#34;bkt --ttl=10m --scope=\&amp;#34;$(git rev-parse --show-toplevel):$(git branch --show-current)\&amp;#34; -- gh pr view --json number,url --template &amp;#39;{{printf \&amp;#34;\\033]8;;%s\\007#%v\\033]8;;\\007\&amp;#34; .url .number}}&amp;#39; 2&amp;gt;/dev/null&amp;#34;&#xA; when = &amp;#34;git rev-parse --is-inside-work-tree 2&amp;gt;/dev/null&amp;#34;&#xA; format = &amp;#34; [$output](magenta)&amp;#34;&#xA; ignore_timeout = true&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I tested the full Starship-to-zsh path against the branch for&#xA;&lt;a href=&#34;https://github.com/thiagowfx/.dotfiles/pull/78&#34;&gt;PR #78&lt;/a&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% tmp=$(mktemp -d)&#xA;% git -C &amp;#34;$tmp&amp;#34; init -q&#xA;% git -C &amp;#34;$tmp&amp;#34; remote add origin https://github.com/thiagowfx/.dotfiles.git&#xA;% git -C &amp;#34;$tmp&amp;#34; switch -q -c renovate/web-tree-sitter-0.x&#xA;% (cd &amp;#34;$tmp&amp;#34; &amp;amp;&amp;amp; STARSHIP_SHELL=zsh starship prompt) |&#xA;    zsh -c &amp;#39;prompt=$(cat); print -nP -- &amp;#34;$prompt&amp;#34;&amp;#39; |&#xA;    python3 -c &amp;#39;import sys&#xA;out = sys.stdin.buffer.read()&#xA;expected = b&amp;#34;\x1b]8;;https://github.com/thiagowfx/.dotfiles/pull/78\x07#78\x1b]8;;\x07&amp;#34;&#xA;assert expected in out&#xA;print(&amp;#34;PASS: zsh-rendered Starship prompt links #78 to PR URL&amp;#34;)&amp;#39;&#xA;PASS: zsh-rendered Starship prompt links #78 to PR URL&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;Cmd&lt;/code&gt; + click on the number now opens the PR in Ghostty.&lt;/p&gt;&#xA;&lt;p&gt;That test covered zsh only. Bash exposed a prompt alignment bug two weeks later.&#xA;Text from the command line appeared at the right edge and before the prompt.&lt;/p&gt;&#xA;&lt;p&gt;Starship did not mark the complete OSC 8 sequence as non-printing for Bash:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;\[\x1b]8;;https://github.com\]/[redacted]/[redacted]/pull/7163\x07#7163\[\x1b]8;;\x07&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Only &lt;code&gt;ESC ] 8 ;; https://github.com&lt;/code&gt; was inside Readline&amp;rsquo;s &lt;code&gt;\[&lt;/code&gt; and &lt;code&gt;\]&lt;/code&gt;&#xA;markers. Bash counted the remaining URL as visible prompt text, so its cursor&#xA;position was wrong.&lt;/p&gt;&#xA;&lt;p&gt;I reverted the hyperlink:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt; [custom.github_pr]&#xA;-command = &amp;#34;bkt --ttl=10m --scope=\&amp;#34;$(git rev-parse --show-toplevel):$(git branch --show-current)\&amp;#34; -- gh pr view --json number,url --template &amp;#39;{{printf \&amp;#34;\\033]8;;%s\\007#%v\\033]8;;\\007\&amp;#34; .url .number}}&amp;#39; 2&amp;gt;/dev/null&amp;#34;&#xA;&amp;#43;command = &amp;#34;bkt --ttl=10m --scope=\&amp;#34;$(git rev-parse --show-toplevel):$(git branch --show-current)\&amp;#34; -- gh pr view --json number --jq &amp;#39;\&amp;#34;#\&amp;#34; &amp;#43; (.number | tostring)&amp;#39; 2&amp;gt;/dev/null&amp;#34;&#xA; when = &amp;#34;git rev-parse --is-inside-work-tree 2&amp;gt;/dev/null&amp;#34;&#xA; format = &amp;#34; [$output](magenta)&amp;#34;&#xA; ignore_timeout = true&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The PR number is plain text again. The Bash prompt now stays aligned.&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;🤖 &lt;em&gt;Drafted with &lt;a href=&#34;https://github.com/thiagowfx/skills/blob/master/plugins/thiagowfx/skills/bloggify/SKILL.md&#34;&gt;&lt;code&gt;/bloggify&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt; ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: starship: hyperlink github PR number&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>mise: prune old installations
      </title>
      <link>https://perrotta.dev/2026/09/mise-prune-old-installations/</link>
      <pubDate>Thu, 03 Sep 2026 13:49:39 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>dev</category>
      <guid>https://perrotta.dev/2026/09/mise-prune-old-installations/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/mise-one-frontend-for-tool-versions/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Today I learned&lt;/strong&gt;: &lt;a href=&#34;https://mise.jdx.dev/&#34;&gt;mise&lt;/a&gt; can remove tool versions no&#xA;longer referenced by its tracked configuration files.&lt;/p&gt;&#xA;&lt;p&gt;List versions eligible for cleanup (dry-run):&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% mise ls --prunable&#xA;% mise prune --tools --dry-run&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Run the cleanup (without &lt;code&gt;--dry-run&lt;/code&gt;):&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% mise prune --tools&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;mise prune&lt;/code&gt; also handles stale tracked configuration links:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% mise prune --dry-run&#xA;mise pruned configuration links [dryrun]&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;For an one-off cleanup, &lt;code&gt;mise uninstall&lt;/code&gt; removes selected versions instead:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% mise uninstall node@18.0.0&#xA;% mise uninstall --all node&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The first command removes one version. The second removes every installed&#xA;version of one tool. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: mise: prune old installations&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pi: enable fullscreen TUI mode
      </title>
      <link>https://perrotta.dev/2026/09/pi-enable-fullscreen-tui-mode/</link>
      <pubDate>Thu, 03 Sep 2026 13:48:57 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>pi</category>
      <guid>https://perrotta.dev/2026/09/pi-enable-fullscreen-tui-mode/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Today I learned&lt;/strong&gt;: &lt;a href=&#34;https://pi.dev/&#34;&gt;pi&lt;/a&gt; can keep its editor and footer at the&#xA;bottom of the terminal with fullscreen TUI mode, similarly to Claude Code.&lt;/p&gt;&#xA;&lt;p&gt;The regular mode leaves terminal scrollback in charge. The &lt;code&gt;--tui-mode&lt;/code&gt; option&#xA;switches to a viewport managed by Pi:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% pi --help | rg -- &amp;#39;--tui-mode|TUI mode&amp;#39;&#xA;  --tui-mode &amp;lt;mode&amp;gt;              TUI mode: regular (default) or fullscreen&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;For an one-off session:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% pi --tui-mode fullscreen&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;For a persistent default, add &lt;code&gt;tuiMode&lt;/code&gt; to &lt;code&gt;~/.pi/agent/settings.json&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;diff --git pi/.pi/agent/settings.json pi/.pi/agent/settings.json&#xA;index 7d2fee8b..57fd5094 100644&#xA;--- pi/.pi/agent/settings.json&#xA;&amp;#43;&amp;#43;&amp;#43; pi/.pi/agent/settings.json&#xA;@@ -41,6 &amp;#43;41,7 @@&#xA;   &amp;#34;showCacheMissNotices&amp;#34;: true,&#xA;   &amp;#34;theme&amp;#34;: &amp;#34;catppuccin-mocha&amp;#34;,&#xA;   &amp;#34;treeFilterMode&amp;#34;: &amp;#34;user-only&amp;#34;,&#xA;&amp;#43;  &amp;#34;tuiMode&amp;#34;: &amp;#34;fullscreen&amp;#34;,&#xA;   &amp;#34;warnings&amp;#34;: {&#xA;     &amp;#34;anthropicExtraUsage&amp;#34;: false&#xA;   }&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The transcript scrolls inside Pi. The editor, queued messages, widgets, and&#xA;footer stay docked at the bottom.&lt;/p&gt;&#xA;&lt;p&gt;The same setting is available under &lt;code&gt;/settings&lt;/code&gt; as &lt;strong&gt;TUI mode&lt;/strong&gt;.&#xA;&lt;a href=&#34;https://github.com/earendil-works/pi/releases/tag/v0.84.4&#34;&gt;Pi 0.84.4&lt;/a&gt; supports&#xA;&lt;code&gt;regular&lt;/code&gt; and &lt;code&gt;fullscreen&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;Fullscreen mode also owns mouse selection. Dragging with the primary mouse button&#xA;copies the selected text on release by default:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-json&#34;&gt;{&#xA;  &amp;#34;fullscreenCopyOnSelect&amp;#34;: true&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Pi confirms the copy with a reverse-video &lt;code&gt;Copied!&lt;/code&gt; flash in the top-right corner&#xA;for one second, which is quite easy to miss. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: pi: enable fullscreen TUI mode&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pi/&#34;&gt;#pi&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>direnv: silence a blocked .envrc
      </title>
      <link>https://perrotta.dev/2026/09/direnv-silence-a-blocked-.envrc/</link>
      <pubDate>Wed, 02 Sep 2026 16:22:01 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>dev</category>
      <guid>https://perrotta.dev/2026/09/direnv-silence-a-blocked-.envrc/</guid>
      <description>&lt;p&gt;♠ Sometimes we simply do not want to activate &lt;a href=&#34;https://direnv.net/&#34;&gt;&lt;code&gt;direnv&lt;/code&gt;&lt;/a&gt; in a&#xA;given project.&lt;/p&gt;&#xA;&lt;p&gt;Deny it for the current directory:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% direnv deny .&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;To enable it later:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% direnv allow .&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;This is necessary to make it stop nagging you each time you &lt;code&gt;cd&lt;/code&gt; into a project&#xA;(directory) with &lt;code&gt;.envrc&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% cdtmp&#xA;/var/folders/yr/6sw3yylx6gjcy5jr38d6j6000000gn/T/thiago.perrotta-2026-09-02-k5lfb0&#xA;&#xA;thiago.perrotta /var/folders/yr/6sw3yylx6gjcy5jr38d6j6000000gn/T/thiago.perrotta-2026-09-02-k5lfb0&#xA;% touch .envrc&#xA;direnv: error /private/var/folders/yr/6sw3yylx6gjcy5jr38d6j6000000gn/T/thiago.perrotta-2026-09-02-k5lfb0/.envrc is blocked. Run `direnv allow` to approve its content&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: direnv: silence a blocked .envrc&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>Hacker News RSS (hnrss): replace broken feed endpoint
      </title>
      <link>https://perrotta.dev/2026/09/hacker-news-rss-hnrss-replace-broken-feed-endpoint/</link>
      <pubDate>Wed, 02 Sep 2026 13:19:22 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>dev</category>
      <category>selfhosted</category>
      <guid>https://perrotta.dev/2026/09/hacker-news-rss-hnrss-replace-broken-feed-endpoint/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2022/02/praise-for-blog-aggregators/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: my filtered Hacker News feed stopped working in&#xA;Miniflux because its HNRSS endpoint returned a bad gateway response.&lt;/p&gt;&#xA;&lt;p&gt;This has been happening for a couple of weeks, to the point it became&#xA;unreliable.&lt;/p&gt;&#xA;&lt;p&gt;The feed selected stories with more than 650 points:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;https://hnrss.org/newest?points=650&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Miniflux reported the upstream failure without hiding it:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;The website is not available at the moment due to a bad gateway error.&#xA;The problem is not on Miniflux side. Please, try again later.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;HNRSS is open source, and another public instance runs a&#xA;&lt;a href=&#34;https://github.com/kakwa/hnrss-ai-filtering&#34;&gt;fork&lt;/a&gt; against the same Hacker News&#xA;Algolia data. So I replaced the domain:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;-https://hnrss.org/newest?points=650&#xA;&amp;#43;https://hnrss.kakwalab.ovh/newest?points=650&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The replacement returned an XML feed with 20 items:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% curl -L --max-time 15 -sS \&#xA;    &amp;#39;https://hnrss.kakwalab.ovh/newest?points=650&amp;#39; \&#xA;    -o /tmp/hnrss.xml \&#xA;    -w &amp;#39;%{http_code} %{content_type} %{size_download} bytes\n&amp;#39;&#xA;200 application/xml; charset=utf-8 15929 bytes&#xA;&#xA;% python3 -c &amp;#39;import xml.etree.ElementTree as ET; \&#xA;r=ET.parse(&amp;#34;/tmp/hnrss.xml&amp;#34;).getroot(); \&#xA;i=r.findall(&amp;#34;./channel/item&amp;#34;); print(len(i)); print(i[0].findtext(&amp;#34;title&amp;#34;))&amp;#39;&#xA;20&#xA;Hang on to Your Firefox&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: Hacker News RSS (hnrss): replace broken feed endpoint&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/selfhosted/&#34;&gt;#selfhosted&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>AgentsView vs fast-resume
      </title>
      <link>https://perrotta.dev/2026/09/agentsview-vs-fast-resume/</link>
      <pubDate>Tue, 01 Sep 2026 20:55:14 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>pkm</category>
      <category>privacy</category>
      <guid>https://perrotta.dev/2026/09/agentsview-vs-fast-resume/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/05/fast-resume-search-coding-agent-sessions/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: does &lt;a href=&#34;https://www.agentsview.io/&#34;&gt;AgentsView&lt;/a&gt; replace&#xA;&lt;a href=&#34;https://github.com/angristan/fast-resume&#34;&gt;&lt;code&gt;fast-resume&lt;/code&gt;&lt;/a&gt;, or do they solve&#xA;different problems?&lt;/p&gt;&#xA;&lt;p&gt;I installed both and compared their current releases:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% agentsview --version&#xA;agentsview v0.41.1 (commit a902515a, built 2026-08-18T13:34:56Z)&#xA;% fr --version&#xA;fr 2.11.2&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;fr&lt;/code&gt; has one job: search local agent sessions and resume one in its native&#xA;agent. Search is typo-tolerant. &lt;code&gt;Enter&lt;/code&gt; replaces &lt;code&gt;fr&lt;/code&gt; with the original agent&#xA;process and restores its working directory:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Agent stores ──► adapters ──► normalized sessions ──► Tantivy index&#xA;                                                        │&#xA;Terminal ◄──── resume handoff ◄──── TUI/search ◄────────┘&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;AgentsView is a persistent SQLite archive with a desktop app, local web UI,&#xA;CLI, REST API, MCP server, cost reports, and session health signals. Its search&#xA;surface is correspondingly larger:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% agentsview session search --help | grep -E -- &amp;#39;--(fts|hybrid|regex|semantic|in )&amp;#39;&#xA;      --fts                      Fast tokenized FTS over messages only&#xA;      --hybrid                   Hybrid semantic &amp;#43; full-text search (reciprocal rank fusion)&#xA;      --in string                Comma-separated sources: messages,tool_input,tool_result (default all)&#xA;      --regex                    Treat pattern as an RE2 regex&#xA;      --semantic                 Semantic (vector) search over user/assistant messages&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;AgentsView documents 59 session sources against &lt;code&gt;fr&lt;/code&gt;&amp;rsquo;s 12. Resume support is&#xA;the opposite: AgentsView maps nine agent types, while &lt;code&gt;fr&lt;/code&gt; has handoff commands&#xA;for all twelve, including Pi:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;# fast-resume&#xA;Pi → pi --session &amp;lt;id&amp;gt;&#xA;&#xA;# AgentsView resume map&#xA;claude, codex, traex, copilot, cursor, gemini, opencode, amp, kiro&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The local footprint shows the difference in scope:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% du -sh /opt/homebrew/Cellar/fast-resume/2.11.2 \&#xA;    /Applications/AgentsView.app ~/.cache/fast-resume ~/.agentsview&#xA;14M     /opt/homebrew/Cellar/fast-resume/2.11.2&#xA;116M    /Applications/AgentsView.app&#xA;772K    /Users/tperrotta/.cache/fast-resume&#xA;28M     /Users/tperrotta/.agentsview&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;fr&lt;/code&gt; treats its index as a disposable cache. AgentsView keeps sessions until&#xA;&lt;code&gt;agentsview prune&lt;/code&gt; removes them. It also sends an &lt;a href=&#34;https://www.agentsview.io/configuration/#privacy-and-telemetry&#34;&gt;anonymous daily liveness&#xA;ping&lt;/a&gt; and checks&#xA;for updates; &lt;code&gt;fr&lt;/code&gt; 2.11.2 has neither mechanism.&lt;/p&gt;&#xA;&lt;p&gt;Given the aforementioned trade-offs: I am happier with &lt;code&gt;fr&lt;/code&gt;. KISS. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: AgentsView vs fast-resume&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pkm/&#34;&gt;#pkm&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/privacy/&#34;&gt;#privacy&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>kargo: quote numeric-looking image tags
      </title>
      <link>https://perrotta.dev/2026/09/kargo-quote-numeric-looking-image-tags/</link>
      <pubDate>Tue, 01 Sep 2026 15:39:56 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>argocd</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/09/kargo-quote-numeric-looking-image-tags/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/kargo-notify-when-a-soak-completes/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: a Kargo promotion turned a valid all-digit Git SHA into&#xA;scientific notation.&lt;/p&gt;&#xA;&lt;p&gt;The Freight contained an image tag and a Helm chart version:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;15abd266dc635e7b9f3fe740441a73cd3cd549e7  937844257  0.5.1&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;937844257&lt;/code&gt; looked like a build number. It was the first nine characters of a&#xA;real commit:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git rev-parse 937844257&#xA;9378442576c62b45cba4b41d8b4eb09a763da1ad&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The Stage passed the tag directly to &lt;a href=&#34;https://docs.kargo.io/user-guide/reference-docs/promotion-steps/yaml-update&#34;&gt;&lt;code&gt;yaml-update&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;- uses: yaml-update&#xA;  config:&#xA;    path: ./gitrepo/apps/overlays/g02/sessions/patches.yaml&#xA;    updates:&#xA;      - key: 1.value.image.tag&#xA;        value: ${{ imageFrom(vars.imageRepo).Tag }}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Kargo &lt;a href=&#34;https://docs.kargo.io/user-guide/reference-docs/expressions/#types&#34;&gt;coerces expression results&lt;/a&gt;&#xA;that look like JSON numbers. The promotion log showed the result:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Updated ./gitrepo/apps/overlays/g02/sessions/patches.yaml&#xA;&#xA;- 1.value.image.tag: 9.37844257e&amp;#43;08&#xA;- 3.value: ~0.5.0&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The generated Git diff therefore pointed at an image tag that did not exist:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt; image:&#xA;-  tag: cd3a31000&#xA;&amp;#43;  tag: 9.37844257e&amp;#43;08&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Kargo provides &lt;code&gt;quote()&lt;/code&gt; for exactly this case:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;-        value: ${{ imageFrom(vars.imageRepo).Tag }}&#xA;&amp;#43;        value: ${{ quote(imageFrom(vars.imageRepo).Tag) }}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I changed all image-tag writes across the eight rollout Stages and checked&#xA;the same condition before and after:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;before fix: 20 unsafe image-tag updates&#xA;after fix: 0 unsafe image-tag updates&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% prek run kargo-promotion-tasks --all-files&#xA;Check Kargo PromotionTask references.....................................Passed&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;An &lt;a href=&#34;https://github.com/akuity/kargo/issues/876&#34;&gt;older Kargo bug report&lt;/a&gt; even&#xA;used an all-digit short SHA as its example. Git hashes contain hexadecimal&#xA;characters, but none of those characters has to be a letter.&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;🤖 &lt;em&gt;Drafted with &lt;a href=&#34;https://github.com/thiagowfx/skills/blob/master/plugins/thiagowfx/skills/bloggify/SKILL.md&#34;&gt;&lt;code&gt;/bloggify&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt; ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: kargo: quote numeric-looking image tags&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/argocd/&#34;&gt;#argocd&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/kubernetes/&#34;&gt;#kubernetes&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>prek 0.5: auto-update → update
      </title>
      <link>https://perrotta.dev/2026/09/prek-0.5-auto-update-update/</link>
      <pubDate>Tue, 01 Sep 2026 12:40:57 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>dev</category>
      <category>pre-commit</category>
      <guid>https://perrotta.dev/2026/09/prek-0.5-auto-update-update/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/04/migrating-from-pre-commit-to-prek/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: prek 0.5 removed &lt;code&gt;prek auto-update&lt;/code&gt;, which broke every&#xA;scheduled updater (github workflows) we have.&lt;/p&gt;&#xA;&lt;p&gt;The workflow installed the latest stable release. One week it resolved 0.4.14:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Resolved &amp;#34;latest&amp;#34; to latest stable release 0.4.14&#xA;Using prek 0.4.14&#xA;Run prek auto-update --freeze --cooldown-days 7 --jobs &amp;#34;$(nproc)&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The next week it resolved 0.5.0 and stopped before updating any hook:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Resolved &amp;#34;latest&amp;#34; to latest stable release 0.5.0&#xA;Using prek 0.5.0&#xA;Run prek auto-update --freeze --cooldown-days 7 --jobs &amp;#34;$(nproc)&amp;#34;&#xA;error: unexpected argument &amp;#39;--freeze&amp;#39; found&#xA;&#xA;  tip: to pass &amp;#39;--freeze&amp;#39; as a value, use &amp;#39;-- --freeze&amp;#39;&#xA;&#xA;Usage: prek &amp;lt;HOOK|PROJECT&amp;gt;...&#xA;&#xA;Error: Process completed with exit code 2.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The &lt;a href=&#34;https://github.com/j178/prek/releases/tag/v0.5.0&#34;&gt;0.5.0 release notes&lt;/a&gt;&#xA;listed the breaking change:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;&lt;code&gt;prek auto-update&lt;/code&gt; has been removed. Use &lt;code&gt;prek update&lt;/code&gt;, or &lt;code&gt;prek autoupdate&lt;/code&gt;&#xA;for drop-in compatibility with &lt;code&gt;pre-commit&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;I switched to the canonical command in workflows and docs:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;-      - name: Run prek auto-update&#xA;-        run: prek auto-update --freeze --jobs &amp;#34;$(nproc)&amp;#34;&#xA;&amp;#43;      - name: Run prek update&#xA;&amp;#43;        run: prek update --freeze --jobs &amp;#34;$(nproc)&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Five of my personal repositories had the old comman:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;check-json-schema-meta   9bf47ea fix(ci): use prek update command&#xA;pancake                  08e7540 fix(ci): use prek update command&#xA;perrotta.dev             a9766c9931 fix(ci): use prek update command&#xA;pre-commit-hooks         965f3ff fix(ci): use prek update command&#xA;skills                   9d81d26 fix(ci): use prek update command&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The new command accepted the same options and completed a dry-run:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% prek update --freeze --jobs 1 --dry-run&#xA;https://github.com/google/keep-sorted&#xA;  would update rev `339d935575ef7d92f2c9b4df9ce4b724d73c9201` (frozen: v0.9.1) -&amp;gt; `b225c42a9a8f480d760cf967d1e3a839060b242c` (frozen: v0.10.0)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: prek 0.5: auto-update → update&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pre-commit/&#34;&gt;#pre-commit&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>external secrets: repair a CRD upgrade
      </title>
      <link>https://perrotta.dev/2026/08/external-secrets-repair-a-crd-upgrade/</link>
      <pubDate>Mon, 31 Aug 2026 16:29:01 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>argocd</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/08/external-secrets-repair-a-crd-upgrade/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: an External Secrets Operator upgrade left different&#xA;clusters in different states, and a normal ArgoCD retry could not repair them.&lt;/p&gt;&#xA;&lt;p&gt;The upgrade moved from ESO 0.9.20 to 2.9.0. Two CRDs rendered at about 707 KB.&#xA;Client-side apply tried to store each complete manifest in an annotation capped&#xA;at 262144 bytes:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;metadata.annotations: Too long: may not be more than 262144 bytes&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Server-side apply avoids&#xA;&lt;code&gt;kubectl.kubernetes.io/last-applied-configuration&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl apply --server-side --force-conflicts -f crd-secretstores.yaml&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That fixed only half of the problem. The old chart configured CRD conversion&#xA;through the ESO webhook. The new chart omitted the conversion block, but&#xA;server-side apply does not remove a field that the desired manifest never&#xA;mentions. Reads still went to a &lt;code&gt;/convert&lt;/code&gt; endpoint that ESO 2.9.0 no longer&#xA;served:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;conversion webhook for external-secrets.io/v1beta1, Kind=SecretStore failed&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The conversion field had to be removed before the new schema was applied:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl patch crd/secretstores.external-secrets.io --type=merge \&#xA;    -p &amp;#39;{&amp;#34;spec&amp;#34;:{&amp;#34;conversion&amp;#34;:{&amp;#34;strategy&amp;#34;:&amp;#34;None&amp;#34;,&amp;#34;webhook&amp;#34;:null}}}&amp;#39;&#xA;% kubectl apply --server-side --force-conflicts -f crd-secretstores.yaml&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Order matters. Applying the schema first creates a window where every&#xA;&lt;code&gt;SecretStore&lt;/code&gt; read reaches the dead webhook.&lt;/p&gt;&#xA;&lt;p&gt;One fixed sequence was still not enough. A cluster with the old controller and&#xA;old CRDs was healthy. Applying the v1 CRDs before ArgoCD deployed ESO 2.9.0&#xA;would instead produce this controller error:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;no matches for kind&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I made the upgrade script classify live state before changing it:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;case &amp;#34;$IMG_TAG:$V1_COUNT:$WEBHOOK_COUNT&amp;#34; in&#xA;  v2.9.*:3:0|2.9.*:3:0)  CLASS=DONE ;;&#xA;  v2.9.*:*|2.9.*:*)      CLASS=NEEDS-CRD-FIX ;;&#xA;  v0.9.*:0:*|0.9.*:0:*)  CLASS=NOT-STARTED ;;&#xA;  v0.9.*:*|0.9.*:*)      CLASS=MIXED ;;&#xA;  *)                      CLASS=UNKNOWN ;;&#xA;esac&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;DONE&lt;/code&gt; is a no-op. &lt;code&gt;NEEDS-CRD-FIX&lt;/code&gt; patches conversion, renders all three CRDs&#xA;from the pinned Helm chart, applies them server-side, and restarts the&#xA;controller. &lt;code&gt;MIXED&lt;/code&gt; and &lt;code&gt;UNKNOWN&lt;/code&gt; stop instead of guessing.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;NOT-STARTED&lt;/code&gt; also stops by default. Its separate opt-in path enables ArgoCD&#xA;auto-sync, waits for the new controller to land, then asks for a second run:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;$ALLOW_NOT_STARTED || die &amp;#34;refusing without --allow-not-started&amp;#34;&#xA;run kc patch app external-secrets -n argocd --type=merge \&#xA;  -p &amp;#39;{&amp;#34;spec&amp;#34;:{&amp;#34;syncPolicy&amp;#34;:{&amp;#34;automated&amp;#34;:{&#xA;    &amp;#34;enabled&amp;#34;:true,&amp;#34;prune&amp;#34;:true,&amp;#34;selfHeal&amp;#34;:true&#xA;  }}}}&amp;#39;&#xA;info &amp;#34;Then: $0 $GARDEN&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The final checks compare custom resource counts, read all three resource kinds,&#xA;check every &lt;code&gt;ExternalSecret&lt;/code&gt;, scan controller logs, and require ArgoCD to&#xA;converge:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;for k in clustersecretstores secretstores externalsecrets; do&#xA;  kc_ok get &amp;#34;$k.external-secrets.io&amp;#34; -A || FAIL=true&#xA;done&#xA;&#xA;if (( CSS_AFTER &amp;lt; CSS_BEFORE || SS_AFTER &amp;lt; SS_BEFORE || \&#xA;      ES_AFTER &amp;lt; ES_BEFORE )); then&#xA;  FAIL=true&#xA;fi&#xA;&#xA;APP=&amp;#34;$(kc get app external-secrets -n argocd \&#xA;  -o &amp;#34;jsonpath={.status.sync.status}/{.status.health.status}&amp;#34;)&amp;#34;&#xA;[[ &amp;#34;$APP&amp;#34; == &amp;#34;Synced/Healthy&amp;#34; ]] || FAIL=true&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The repair became an idempotent state transition instead of a command sequence.&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;🤖 &lt;em&gt;Drafted with &lt;a href=&#34;https://github.com/thiagowfx/skills/blob/master/plugins/thiagowfx/skills/bloggify/SKILL.md&#34;&gt;&lt;code&gt;/bloggify&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt; ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: external secrets: repair a CRD upgrade&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/argocd/&#34;&gt;#argocd&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/kubernetes/&#34;&gt;#kubernetes&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>★ git: diff against the default branch
      </title>
      <link>https://perrotta.dev/2026/08/git-diff-against-the-default-branch/</link>
      <pubDate>Mon, 31 Aug 2026 14:28:49 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bestof</category>
      <category>dev</category>
      <category>git</category>
      <guid>https://perrotta.dev/2026/08/git-diff-against-the-default-branch/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: create a git alias / script to diff against the default&#xA;upstream branch, inspired by chromium &lt;a href=&#34;https://commondatastorage.googleapis.com/chrome-infra-docs/flat/depot_tools/docs/html/git-upstream-diff.html&#34;&gt;&lt;code&gt;depot_tools(7)&lt;/code&gt;&lt;/a&gt;&#xA;&lt;code&gt;git-upstream-diff(1)&lt;/code&gt;:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;git-upstream-diff — Print a diff of the current branch, compared to its&#xA;upstream.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;I used to have an alias for it in 2024, but I did not have a reliable way at the&#xA;time to find whether a repository used &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;master&lt;/code&gt;, or something else:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-ini&#34;&gt;udiff = !git diff $(git show-branch --merge-base HEAD 2&amp;gt;/dev/null)~1&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;It lasted one month (repository archeology FTW):&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git log --all --format=&amp;#39;%h %ad %s&amp;#39; --date=short --grep=udiff -i&#xA;da961f1 2026-08-31 git: restore udiff command&#xA;a4f13e9 2024-10-07 git: remove udiff&#xA;244ca3b 2024-09-02 git: add udiff alias&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://worktrunk.dev/&#34;&gt;Worktrunk&lt;/a&gt; now gives me what I always wanted:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% wt config state default-branch&#xA;master&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/2026/05/worktrunk/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;As git finds executables named &lt;code&gt;git-&amp;lt;command&amp;gt;&lt;/code&gt; on &lt;code&gt;PATH&lt;/code&gt;, the replacement is&#xA;a small script at &lt;code&gt;git/.bin/git-udiff&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;#!/bin/sh&#xA;#&#xA;# git-udiff - Show changes since the current branch diverged from the default branch&#xA;&#xA;set -e&#xA;&#xA;if [ &amp;#34;$(git rev-parse --is-inside-work-tree 2&amp;gt;/dev/null)&amp;#34; != &amp;#34;true&amp;#34; ]; then&#xA;  echo &amp;#34;Error: Not in a git repository&amp;#34;&#xA;  exit 1&#xA;fi&#xA;&#xA;default_branch=$(wt config state default-branch 2&amp;gt;/dev/null)&#xA;merge_base=$(git merge-base &amp;#34;$default_branch&amp;#34; HEAD)&#xA;&#xA;exec git diff &amp;#34;$merge_base&amp;#34; &amp;#34;$@&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Why a script instead of an alias? Because it is no longer a readable one-liner.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;git merge-base&lt;/code&gt; finds the divergence commit. Diffing from that commit without&#xA;an end revision includes committed, staged, and unstaged tracked changes.&#xA;Forwarding &lt;code&gt;&amp;quot;$@&amp;quot;&lt;/code&gt; keeps regular diff options and path filters working:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git udiff --stat&#xA; claude/.claude/settings.json | 3 &amp;#43;&amp;#43;-&#xA; pi/.pi/agent/settings.json   | 2 &amp;#43;-&#xA; 2 files changed, 3 insertions(&amp;#43;), 2 deletions(-)&#xA;&#xA;% git udiff --name-only -- pi/.pi/agent/settings.json&#xA;pi/.pi/agent/settings.json&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The command is back, now based on repository state instead of a branch-name&#xA;guess:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git show --stat --oneline da961f1&#xA;da961f1 git: restore udiff command&#xA; git/.bin/git-udiff | 15 &amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&#xA; 1 file changed, 15 insertions(&amp;#43;)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: git: diff against the default branch&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bestof/&#34;&gt;#bestof&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/git/&#34;&gt;#git&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>argocd: sync windows block pruning
      </title>
      <link>https://perrotta.dev/2026/08/argocd-sync-windows-block-pruning/</link>
      <pubDate>Mon, 31 Aug 2026 13:00:50 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>argocd</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/08/argocd-sync-windows-block-pruning/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: deleting an ArgoCD &lt;code&gt;Application&lt;/code&gt; from Git (GitOps) does&#xA;not guarantee it will disappear from the cluster.&lt;/p&gt;&#xA;&lt;p&gt;We removed an app-of-apps child from every garden it lived in and merged the&#xA;PR. The parent synced clean:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;phase: Succeeded&#xA;message: successfully synced (all tasks run)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;An hour later, six clusters &lt;em&gt;paged&lt;/em&gt; 🚨 with &lt;code&gt;ArgoCdAppSyncUnknown&lt;/code&gt;. The child&#xA;was still there:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;sync: Unknown&#xA;health: Healthy&#xA;deletionTimestamp:&#xA;finalizers:&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;No &lt;code&gt;deletionTimestamp&lt;/code&gt;, no finalizer stuck — it was never asked to delete.&#xA;The controller logs said why:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;level=info msg=&amp;#34;Sync prevented by sync window&amp;#34; application=datastore-all&#xA;    dest-namespace=infra-services project=garden&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The app was listed in an &lt;code&gt;AppProject&lt;/code&gt; sync window:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-json&#34;&gt;{&#xA;  &amp;#34;kind&amp;#34;: &amp;#34;allow&amp;#34;,&#xA;  &amp;#34;schedule&amp;#34;: &amp;#34;0 23 * * *&amp;#34;,&#xA;  &amp;#34;duration&amp;#34;: &amp;#34;1h&amp;#34;,&#xA;  &amp;#34;timeZone&amp;#34;: &amp;#34;America/New_York&amp;#34;,&#xA;  &amp;#34;applications&amp;#34;: [&amp;#34;...&amp;#34;, &amp;#34;datastore-all&amp;#34;, &amp;#34;...&amp;#34;]&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;An &lt;code&gt;allow&lt;/code&gt; window means auto-sync — including pruning — only runs inside it.&#xA;The parent app re-rendered without the child and dropped it from&#xA;&lt;code&gt;status.resources&lt;/code&gt;, but pruning a tracked resource is itself a sync&#xA;operation, so ArgoCD deferred it to 23:00–00:00 New York time. Meanwhile the&#xA;orphaned &lt;code&gt;Application&lt;/code&gt; still pointed at a values file that no longer existed&#xA;on &lt;code&gt;HEAD&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Failed to load target state: failed to generate manifest for source 1 of 2:&#xA;    ... open apps/overlays/g28/datastore-all/values.yaml: no such file or directory&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Manifest generation failure is a sync status of &lt;code&gt;Unknown&lt;/code&gt;, which is exactly&#xA;what fired the alert.&lt;/p&gt;&#xA;&lt;p&gt;The object had no finalizer, so deleting it directly only removes the&#xA;bookkeeping, not the workload it deployed:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n argocd delete app datastore-all&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The actual &lt;code&gt;infra-services&lt;/code&gt; resources it created are still tracked by label&#xA;and get pruned once the window opens — or by hand in the meantime. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: argocd: sync windows block pruning&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/argocd/&#34;&gt;#argocd&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/kubernetes/&#34;&gt;#kubernetes&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>★ TV Shows
      </title>
      <link>https://perrotta.dev/2026/08/tv-shows/</link>
      <pubDate>Mon, 31 Aug 2026 02:59:17 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bestof</category>
      <category>bloggify</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/08/tv-shows/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/books/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;I added a &lt;a href=&#34;https://perrotta.dev/tv-shows/&#34;&gt;TV shows&lt;/a&gt; page with a sample of my favorites, inspired&#xA;by &lt;a href=&#34;https://michaelharley.net/watching/&#34;&gt;Michael Harley&amp;rsquo;s watching page&lt;/a&gt; and&#xA;&lt;a href=&#34;https://michael.stapelberg.ch/series/&#34;&gt;Michael Stapelberg&amp;rsquo;s series page&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;The list lives in YAML. Trakt keeps my full watch history; this file stays&#xA;small, alphabetical, and intentionally incomplete:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;shows:&#xA;  # keep-sorted start case=no by_regex=title:\s&amp;#43;&amp;#34;?([^&amp;#34;]&amp;#43;)&#xA;  - title: 13 Reasons Why&#xA;    years: 2017–2020&#xA;    seasons: 4&#xA;    url: https://www.themoviedb.org/tv/66788-13-reasons-why&#xA;    cover: https://media.themoviedb.org/t/p/w500/nel144y4dIOdFFid6twN5mAX9Yd.jpg&#xA;  - title: 3%&#xA;    years: 2016–2020&#xA;    seasons: 4&#xA;    url: https://www.themoviedb.org/tv/68467-3&#xA;    cover: https://media.themoviedb.org/t/p/w500/uLBJSLuAQ8UqLIKZVWG3uNEXwjt.jpg&#xA;  # keep-sorted end&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A custom Hugo layout turns each record into a poster card. Show details and&#xA;posters come from &lt;a href=&#34;https://www.themoviedb.org/&#34;&gt;The Movie Database&lt;/a&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-go-html-template&#34;&gt;&amp;lt;div class=&amp;#34;tv-shows-grid&amp;#34;&amp;gt;&#xA;  {{- range $data.shows }}&#xA;  &amp;lt;div class=&amp;#34;tv-shows-item&amp;#34;&amp;gt;&#xA;    &amp;lt;a href=&amp;#34;{{ .url }}&amp;#34; title=&amp;#34;{{ .title }} ({{ .years }})&amp;#34;&amp;gt;&#xA;      &amp;lt;img class=&amp;#34;tv-shows-cover&amp;#34; src=&amp;#34;{{ .cover }}&amp;#34; alt=&amp;#34;Poster for {{ .title }}&amp;#34; loading=&amp;#34;lazy&amp;#34; decoding=&amp;#34;async&amp;#34; referrerpolicy=&amp;#34;no-referrer&amp;#34;&amp;gt;&#xA;      &amp;lt;div class=&amp;#34;tv-shows-item-title&amp;#34;&amp;gt;{{ .title }}&amp;lt;/div&amp;gt;&#xA;    &amp;lt;/a&amp;gt;&#xA;    &amp;lt;div class=&amp;#34;tv-shows-item-meta&amp;#34;&amp;gt;&#xA;      {{ .years }} &amp;amp;middot; {{ .seasons }} {{ cond (eq .seasons 1) &amp;#34;season&amp;#34; &amp;#34;seasons&amp;#34; }}&#xA;    &amp;lt;/div&amp;gt;&#xA;  &amp;lt;/div&amp;gt;&#xA;  {{- end }}&#xA;&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The data has a JSON schema, and the page now sits next to Books in the More&#xA;menu:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git show --stat --oneline 97dc4bde16&#xA;97dc4bde16 add TV shows page&#xA;6 files changed, 305 insertions(&amp;#43;), 3 deletions(-)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;TV show recommendations are almost always welcome. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: TV Shows&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bestof/&#34;&gt;#bestof&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>ssh: pipe SQL through doas with base64
      </title>
      <link>https://perrotta.dev/2026/08/ssh-pipe-sql-through-doas-with-base64/</link>
      <pubDate>Sat, 29 Aug 2026 02:44:43 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>linux</category>
      <category>ssh</category>
      <guid>https://perrotta.dev/2026/08/ssh-pipe-sql-through-doas-with-base64/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: nested shell quotes made a remote PostgreSQL update&#xA;harder to run than the update itself.&lt;/p&gt;&#xA;&lt;p&gt;The target is an Alpine Linux host. Its Miniflux configuration contains the database&#xA;connection string, so &lt;code&gt;psql&lt;/code&gt; must run after sourcing &lt;code&gt;/etc/miniflux.conf&lt;/code&gt; through&#xA;&lt;code&gt;doas&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;An inline command quickly becomes a quoting puzzle. SQL contains single quotes,&#xA;Unicode, and sometimes apostrophes. The local shell also expands unquoted&#xA;characters before &lt;code&gt;ssh&lt;/code&gt; sends anything to the server.&lt;/p&gt;&#xA;&lt;p&gt;I wrote SQL to a temporary file, encoded it locally, and sent only base64 over&#xA;SSH:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% base64 /tmp/miniflux-flags.sql | tr -d &amp;#39;\n&amp;#39; | \&#xA;  ssh knol &amp;#39;doas sh -c &amp;#39;\&amp;#39;&amp;#39;&#xA;    . /etc/miniflux.conf&#xA;    base64 -d | psql &amp;#34;$DATABASE_URL&amp;#34; -X -v ON_ERROR_STOP=1&#xA;  &amp;#39;\&amp;#39;&amp;#39;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Remote shell receives a simple command. &lt;code&gt;base64 -d&lt;/code&gt; reconstructs SQL on the&#xA;server. &lt;code&gt;psql&lt;/code&gt; reads it from standard input. &lt;code&gt;ON_ERROR_STOP=1&lt;/code&gt; makes a SQL&#xA;error stop the command instead of continuing to later statements.&lt;/p&gt;&#xA;&lt;p&gt;The file contained real, guarded updates:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-sql&#34;&gt;BEGIN;&#xA;UPDATE feeds SET title = title || &amp;#39; 🇺🇸&amp;#39;&#xA;WHERE id = 1394 AND title = &amp;#39;furbo.org: Craig Hockenberry&amp;#39;&#xA;RETURNING id, title;&#xA;UPDATE feeds SET title = title || &amp;#39; 🇨🇦&amp;#39;&#xA;WHERE id = 1406 AND title = &amp;#39;Ansuz: Matthew Skala&amp;#39;&#xA;RETURNING id, title;&#xA;UPDATE feeds SET title = title || &amp;#39; 🇬🇧&amp;#39;&#xA;WHERE id = 1411 AND title = &amp;#39;Ian Jackson&amp;#39;&#xA;RETURNING id, title;&#xA;-- eight more exact-title guards&#xA;COMMIT;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The remote output stayed useful:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;BEGIN&#xA; id  |              title&#xA;-----&amp;#43;---------------------------------&#xA;1394 | furbo.org: Craig Hockenberry 🇺🇸&#xA;(1 row)&#xA;UPDATE 1&#xA;...&#xA;UPDATE 1&#xA;COMMIT&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A second query verified every ID after commit:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt; id  |              title&#xA;-----&amp;#43;---------------------------------&#xA;1394 | furbo.org: Craig Hockenberry 🇺🇸&#xA;1406 | Ansuz: Matthew Skala 🇨🇦&#xA;1411 | Ian Jackson 🇬🇧&#xA;1444 | Matthew Garrett: mjg59 🇬🇧&#xA;1459 | Cloudscaling by Randy Bias 🇺🇸&#xA;1460 | Marcin Juszkiewicz 🇵🇱&#xA;1464 | Liss is More by Casey Liss 🇺🇸&#xA;1472 | Jerod Santo 🇺🇸&#xA;1482 | Matthias Kirschner 🇩🇪&#xA;1545 | Charles Leifer 🇺🇸&#xA;(10 rows)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Base64 does not encrypt anything. It only moves one opaque payload through&#xA;several shells. That is enough to keep local parsing away from SQL. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: ssh: pipe SQL through doas with base64&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/linux/&#34;&gt;#linux&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/ssh/&#34;&gt;#ssh&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>blog: calendar view
      </title>
      <link>https://perrotta.dev/2026/08/blog-calendar-view/</link>
      <pubDate>Sat, 29 Aug 2026 02:12:41 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>meta</category>
      <guid>https://perrotta.dev/2026/08/blog-calendar-view/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: my blog archive was useful, but did not show its posting&#xA;pattern over time.&lt;/p&gt;&#xA;&lt;p&gt;I liked &lt;a href=&#34;https://blog.jim-nielsen.com/2026/blog-calendar-view/&#34;&gt;Jim Nielsen&amp;rsquo;s calendar view&lt;/a&gt;,&#xA;so I added one too. &lt;a href=&#34;https://perrotta.dev/posts/&#34;&gt;List&lt;/a&gt; remains better for finding a post. The new&#xA;&lt;a href=&#34;https://perrotta.dev/calendar/&#34;&gt;calendar&lt;/a&gt; is for seeing when I published.&lt;/p&gt;&#xA;&lt;p&gt;The calendar and list use one filter. Coding, recipes, and RSS-only posts stay&#xA;out of both:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-go-html-template&#34;&gt;{{- $pages := where .pages &amp;#34;Site.Language.Lang&amp;#34; .lang -}}&#xA;{{- $pages = where $pages &amp;#34;Kind&amp;#34; &amp;#34;page&amp;#34; -}}&#xA;{{- $pages = where $pages &amp;#34;Params.rss_only&amp;#34; &amp;#34;!=&amp;#34; true -}}&#xA;{{- $excludeCategories := slice &amp;#34;coding&amp;#34; &amp;#34;recipes&amp;#34; -}}&#xA;{{- $pages = where $pages &amp;#34;Params.categories&amp;#34; &amp;#34;intersect&amp;#34; $excludeCategories | symdiff $pages -}}&#xA;{{- return $pages -}}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I group posts by ISO date. A day gets a blue dot. A day with a &lt;code&gt;bestof&lt;/code&gt; post&#xA;gets a yellow star. Clicking it shows every post from that date:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-go-html-template&#34;&gt;{{- $postsByDate := newScratch }}&#xA;{{- range $pages }}&#xA;  {{- $key := .Date.Format &amp;#34;2006-01-02&amp;#34; }}&#xA;  {{- $dayPages := $postsByDate.Get $key | default (slice) }}&#xA;  {{- $postsByDate.Set $key ($dayPages | append .) }}&#xA;{{- end }}&#xA;&#xA;&amp;lt;details class=&amp;#34;calendar-day calendar-day--has-post&amp;#34;&amp;gt;&#xA;  &amp;lt;summary&amp;gt;&#xA;    &amp;lt;span class=&amp;#34;calendar-day--post{{ if $featured }} calendar-day--featured{{ end }}&amp;#34;&amp;gt;&#xA;      {{ if $featured }}&amp;lt;span class=&amp;#34;calendar-day--featured-icon&amp;#34;&amp;gt;★&amp;lt;/span&amp;gt;{{ end }}&#xA;    &amp;lt;/span&amp;gt;&#xA;  &amp;lt;/summary&amp;gt;&#xA;  &amp;lt;div class=&amp;#34;calendar-popover&amp;#34;&amp;gt;&#xA;    {{- range $dayPages }}&#xA;    &amp;lt;a href=&amp;#34;{{ .Permalink }}&amp;#34;&amp;gt;&#xA;      &amp;lt;span&amp;gt;{{ if in .Params.tags &amp;#34;bestof&amp;#34; }}★ {{ end }}{{ .Title }}&amp;lt;/span&amp;gt;&#xA;    &amp;lt;/a&amp;gt;&#xA;    {{- end }}&#xA;  &amp;lt;/div&amp;gt;&#xA;&amp;lt;/details&amp;gt;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;It has English, Portuguese, and Italian month labels. The marker target is 24&#xA;pixels tall, and mobile uses two month columns:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-css&#34;&gt;.calendar-day--has-post summary {&#xA;  width: 100%;&#xA;  min-height: 24px;&#xA;}&#xA;&#xA;@media (max-width: 600px) {&#xA;  .calendar-months {&#xA;    grid-template-columns: repeat(2, minmax(0, 1fr));&#xA;  }&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git show --stat --oneline 9c1db78581&#xA;9c1db78581 calendar&#xA;11 files changed, 388 insertions(&amp;#43;), 33 deletions(-)&#xA;&#xA;% hugo --environment production&#xA;                   │  EN  │ PT  │ IT&#xA;───────────────────┼──────┼─────┼────&#xA; Pages             │ 2968 │ 225 │ 21&#xA;Total in 8080 ms&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;∎&lt;/p&gt;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: blog: calendar view&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/meta/&#34;&gt;#meta&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>★ miniflux: add country flags to all feeds 🌐
      </title>
      <link>https://perrotta.dev/2026/08/miniflux-add-country-flags-to-all-feeds/</link>
      <pubDate>Fri, 28 Aug 2026 01:20:26 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bestof</category>
      <category>dev</category>
      <category>selfhosted</category>
      <guid>https://perrotta.dev/2026/08/miniflux-add-country-flags-to-all-feeds/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: given my miniflux server with hundreds of RSS feed&#xA;subscriptions, add emoji country flags to each feed title, representing their&#xA;country of origin and/or the country they&amp;rsquo;re associated with&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;https://perrotta.dev/2026/08/miniflux-add-country-flags-to-all-feeds/#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;&#xA;&lt;p&gt;For example:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Before&lt;/strong&gt;: Not Just Serendipity by Thiago Perrotta&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;After&lt;/strong&gt;: Not Just Serendipity by Thiago Perrotta 🇧🇷🇨🇦&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Some feeds may be identified with multiple flags. The distinction is purposely&#xA;loose.&lt;/p&gt;&#xA;&lt;p&gt;I started to do this tagging process manually, throughout several weeks, until I&#xA;exhausted the ones I knew about.&lt;/p&gt;&#xA;&lt;p&gt;The next natural step, in 2026, is to resort to Gen AI to finish the heavy&#xA;lifting, now that the initial pattern has been established.&lt;/p&gt;&#xA;&lt;p&gt;With my &lt;a href=&#34;https://perrotta.dev/2026/08/chatgpt-openai-codex-plan-usage/&#34;&gt;OpenAI Codex&lt;/a&gt; subscription and pi, I prompted away:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;User: List all prompts I ran in this session, bulleted list:&lt;/p&gt;&#xA;&lt;p&gt;Thinking: Listing all user prompt messages&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&amp;ldquo;Are you able to connect to my miniflux database in knol?&#xA;(ssh knol) Creds are in /etc/miniflux.conf in knol.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;Note that Alpine Linux uses doas, not sudo.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;Are you able to find my list of subscriptions / feeds? There&#xA;should be 687 of these.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;Observe that some feed titles have flags in them.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;I want to add a flag for the origin of each blog. Source a&#xA;few feeds lacking flags and suggest which flags to add.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;LGTM.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;Source 10–15 more blogs.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;LGTM.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;Write a blog post about this work using /bloggify.&amp;rdquo;&lt;/li&gt;&#xA;&lt;li&gt;&amp;ldquo;List all prompts I ran in this session, bulleted list.&amp;rdquo;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;Here are a few interesting bits produced by the agent. Note that I run the&#xA;coding harness (&lt;code&gt;pi&lt;/code&gt;) from my laptop, not from my server. As such, all commands&#xA;that interact with the server do so via &lt;code&gt;ssh&lt;/code&gt;. This is good enough for my&#xA;purposes.&lt;/p&gt;&#xA;&lt;p&gt;Count all feeds:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ssh knol &amp;#39;doas sh -c &amp;#39;\&amp;#39;&amp;#39;. /etc/miniflux.conf; psql &amp;#34;$DATABASE_URL&amp;#34; -X -Atqc &amp;#34;SELECT count(*) FROM feeds&amp;#34;&amp;#39;\&amp;#39;&amp;#39;&amp;#39;&#xA;687&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Some titles already followed the convention:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;780|37signals jobs 🇩🇰🇺🇸&#xA;818|A cup of coffee by Quentin JOLY 🇫🇷&#xA;1366|AI Engineer Guide 🇮🇳&#xA;562|Adam Gordon Bell: Cascade Of Insights 🇨🇦&#xA;916|Adam Johnson 🇬🇧&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A pair of Unicode regional indicators forms a flag. Export the titles and&#xA;count those pairs:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ssh knol &amp;#39;doas sh -c &amp;#39;\&amp;#39;&amp;#39;. /etc/miniflux.conf; psql &amp;#34;$DATABASE_URL&amp;#34; -X -q -c \&#xA;  &amp;#34;COPY (SELECT id, title FROM feeds ORDER BY title) TO STDOUT WITH CSV&amp;#34;&amp;#39;\&amp;#39;&amp;#39;&amp;#39; | \&#xA;  python3 -c &amp;#39;import csv,sys,re; rows=list(csv.reader(sys.stdin)); p=re.compile(r&amp;#34;[\U0001F1E6-\U0001F1FF]{2}&amp;#34;); found=[(i,t,p.findall(t)) for i,t in rows if p.search(t)]; print(f&amp;#34;feeds_with_flags={len(found)} flags={sum(len(x[2]) for x in found)}&amp;#34;)&amp;#39;&#xA;feeds_with_flags=148 flags=158&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I did not infer origin from a name, domain, language, or current residence. Each&#xA;addition needed an explicit source. For example, &lt;a href=&#34;https://adactio.com/about/&#34;&gt;Jeremy&#xA;Keith&lt;/a&gt; says &amp;ldquo;I&amp;rsquo;m from Ireland originally&amp;rdquo;, &lt;a href=&#34;https://andregarzia.com/about.html&#34;&gt;Andre&#xA;Garzia&lt;/a&gt; says he is originally from Brazil,&#xA;and &lt;a href=&#34;https://shibumi.dev/about/&#34;&gt;Christian Rebischke&lt;/a&gt; describes himself as being&#xA;from Germany.&lt;/p&gt;&#xA;&lt;p&gt;The title itself is the metadata. Exact old-title guards made each update&#xA;idempotent and prevented an accidental edit to a renamed feed:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-sql&#34;&gt;BEGIN;&#xA;UPDATE feeds&#xA;SET title = &amp;#39;Christian Rebischke: shibumi 🇩🇪&amp;#39;&#xA;WHERE id = 207 AND title = &amp;#39;Christian Rebischke: shibumi&amp;#39;;&#xA;UPDATE feeds&#xA;SET title = &amp;#39;Adactio: Journal by Jeremy Keith 🇮🇪&amp;#39;&#xA;WHERE id = 1106 AND title = &amp;#39;Adactio: Journal by Jeremy Keith&amp;#39;;&#xA;UPDATE feeds&#xA;SET title = &amp;#39;Adactio: Articles by Jeremy Keith 🇮🇪&amp;#39;&#xA;WHERE id = 1107 AND title = &amp;#39;Adactio: Articles by Jeremy Keith&amp;#39;;&#xA;UPDATE feeds&#xA;SET title = &amp;#39;Andre Alves Garzia 🇧🇷&amp;#39;&#xA;WHERE id = 1481 AND title = &amp;#39;Andre Alves Garzia&amp;#39;;&#xA;COMMIT;&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;BEGIN&#xA;UPDATE 1&#xA;UPDATE 1&#xA;UPDATE 1&#xA;UPDATE 1&#xA;COMMIT&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A second sourced batch added 12 more titles. Compound origins stay compound:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;430|Burak Karakan: ps 🇩🇪🇹🇷&#xA;1022|Amjad Masad 🇯🇴🇺🇸&#xA;1076|Christopher Olah 🇨🇦&#xA;1100|Cognitive Medium by Michael Nielsen 🇦🇺🇺🇸&#xA;1174|Andrej Karpathy 🇸🇰🇨🇦&#xA;1489|Jeff Geerling 🇺🇸&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The same audit now reports:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;total=687&#xA;feeds_with_flags=164&#xA;flags=178&#xA;without_flags=523&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Only 523 to go.&lt;/p&gt;&#xA;&lt;p&gt;This is a practical excuse to tokenmaxx, eh?!&lt;/p&gt;&#xA;&lt;p&gt;Web searches are done with &lt;a href=&#34;https://github.com/thiagowfx/.dotfiles/tree/e6d1452118ad2b2c317545571fd0923519bf7a41/pi/.pi/agent/local/web-search&#34;&gt;pi-web-search&lt;/a&gt;. ∎&lt;/p&gt;&#xA;&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;&#xA;&lt;hr&gt;&#xA;&lt;ol&gt;&#xA;&lt;li id=&#34;fn:1&#34;&gt;&#xA;&lt;p&gt;Why? Purely as a matter of context, and for an extra dose of serendipity&#xA;via diversity. For example, it&amp;rsquo;s often exciting to me to read posts from&#xA;other Canadians, for no particular reason.&amp;#160;&lt;a href=&#34;https://perrotta.dev/2026/08/miniflux-add-country-flags-to-all-feeds/#fnref:1&#34; class=&#34;footnote-backref&#34; role=&#34;doc-backlink&#34;&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: miniflux: add country flags to all feeds 🌐&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bestof/&#34;&gt;#bestof&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/selfhosted/&#34;&gt;#selfhosted&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>claude code: /goal
      </title>
      <link>https://perrotta.dev/2026/08/claude-code-goal/</link>
      <pubDate>Wed, 26 Aug 2026 17:45:34 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>argocd</category>
      <category>bloggify</category>
      <category>claude</category>
      <category>dev</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/08/claude-code-goal/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: a coding agent stops when it thinks it is done, not when&#xA;the thing is actually done.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://code.claude.com/docs/en/goal.md&#34;&gt;&lt;code&gt;/goal&lt;/code&gt;&lt;/a&gt; sets a condition that&#xA;outlives the turn. The docs call it a wrapper around a session-scoped,&#xA;prompt-based Stop hook: every time the agent tries to end its turn, an evaluator&#xA;checks the condition and pushes back if it does not hold.&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;/goal argocd, prometheus and external secrets should all be healthy and synced in cluster #26&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I set that mid-session while debugging a cluster where three Argo CD apps were&#xA;stuck. What followed was the useful part. The agent wrapped up with a tidy&#xA;summary and two pull requests, and got this back:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Stop hook feedback:&#xA;[argocd, prometheus and external secrets should all be healthy and synced in cluster #26]:&#xA;PRs #3064 and #3062 have been opened but not yet merged. The latest status check&#xA;in the transcript confirms external-secrets = &amp;#39;OutOfSync&amp;#39;, argocd = &amp;#39;OutOfSync&amp;#39;,&#xA;prometheus = &amp;#39;Unknown&amp;#39; — none are both &amp;#39;healthy and synced&amp;#39;.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Opening a PR is not the same as the apps being green, and the evaluator reads&#xA;the transcript rather than the closing paragraph. It fired three times. Each&#xA;time the summary was plausible and each time the condition was still false.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;/goal&lt;/code&gt; with no arguments shows the condition, elapsed time, turn count, token&#xA;spend, and the evaluator&amp;rsquo;s last reason. &lt;code&gt;/goal clear&lt;/code&gt; ends it.&lt;/p&gt;&#xA;&lt;p&gt;The failure mode it fixes is specific: an agent that declares victory one step&#xA;early. It does not make the agent smarter — mine still burned several turns on&#xA;wrong theories, and I had to redirect it more than once. It only refuses to let&#xA;&amp;ldquo;I opened a PR&amp;rdquo; stand in for &amp;ldquo;the apps are green&amp;rdquo;. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: claude code: /goal&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/argocd/&#34;&gt;#argocd&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/claude/&#34;&gt;#claude&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/kubernetes/&#34;&gt;#kubernetes&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>Continuity
      </title>
      <link>https://perrotta.dev/2026/08/continuity/</link>
      <pubDate>Tue, 25 Aug 2026 15:40:59 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>dev</category>
      <category>macos</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/08/continuity/</guid>
      <description>&lt;p&gt;♠ A serendipitous discovery: macOS&#xA;&lt;a href=&#34;https://www.apple.com/macos/continuity/&#34;&gt;continuity&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;Picture this:&lt;/p&gt;&#xA;&lt;p&gt;I am using my corp Macbook Pro, whilst my personal Macbook Air happens to be&#xA;close by. Both of their lids are open.&lt;/p&gt;&#xA;&lt;p&gt;In my corp Macbook, for some reason, I decided to move the mouse cursor to the&#xA;edge of the laptop screen, using the trackpad.&lt;/p&gt;&#xA;&lt;p&gt;And then, just like magic, the mouse cursor &lt;em&gt;in my personal Macbook&lt;/em&gt; starts to&#xA;move around.&lt;/p&gt;&#xA;&lt;p&gt;And then I open up a terminal and start to type, from my corp Macbook, and&#xA;somehow characters start to appear in my personal Macbook.&lt;/p&gt;&#xA;&lt;p&gt;Remote trackpad + keyboard control. This is amazing!&lt;/p&gt;&#xA;&lt;p&gt;This post was created in my personal Macbook, typed from my corp keyboard.&lt;/p&gt;&#xA;&lt;p&gt;Naturally, I am signed into my Apple ID in both devices. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: Continuity&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/macos/&#34;&gt;#macos&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pi: prompt template or skill?
      </title>
      <link>https://perrotta.dev/2026/08/pi-prompt-template-or-skill/</link>
      <pubDate>Tue, 25 Aug 2026 01:13:46 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>dev</category>
      <category>pi</category>
      <guid>https://perrotta.dev/2026/08/pi-prompt-template-or-skill/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: I often type &lt;code&gt;commit (only) what you changed&lt;/code&gt; in coding&#xA;agent harnesses. Can I make an abbreviation out of it?&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://pi.dev/&#34;&gt;Pi&lt;/a&gt; turns a Markdown file in &lt;code&gt;~/.pi/agent/prompts/&lt;/code&gt; into a&#xA;/slash command. This one gives me &lt;code&gt;/commit&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-markdown&#34;&gt;---&#xA;description: Commit files changed in this turn without pushing&#xA;---&#xA;Commit what you changed (only). DO NOT push.&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% find ~/.pi/agent/prompts -maxdepth 1 -type f -print -exec sh -c &amp;#39;printf &amp;#34;%s\n&amp;#34; &amp;#34;--- $1&amp;#34;; cat &amp;#34;$1&amp;#34;&amp;#39; _ {} \;&#xA;/Users/thiago.perrotta/.pi/agent/prompts/commit.md&#xA;--- /Users/thiago.perrotta/.pi/agent/prompts/commit.md&#xA;---&#xA;description: Commit files changed in this turn without pushing&#xA;---&#xA;Commit what you changed (only). DO NOT push.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;In Pi, a &lt;a href=&#34;https://pi.dev/docs/latest/prompt-templates&#34;&gt;prompt template&lt;/a&gt; is fixed&#xA;prompt expansion. It is right for a short, stable request:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;Prompt templates are Markdown snippets that expand into full prompts. Type&#xA;/name in the editor to invoke a template, where name is the filename without&#xA;.md.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;A &lt;a href=&#34;https://agentskills.io/home&#34;&gt;skill&lt;/a&gt; is a capability package. Its description&#xA;is available at startup, but Pi loads its&#xA;&lt;a href=&#34;https://pi.dev/docs/latest/skills&#34;&gt;&lt;code&gt;SKILL.md&lt;/code&gt;&lt;/a&gt; only when task matches or I run&#xA;&lt;code&gt;/skill:name&lt;/code&gt;. A skill can carry scripts, references, setup, and a workflow that&#xA;branches on state:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;Skills are self-contained capability packages that the agent loads on-demand.&#xA;A skill provides specialized workflows, setup instructions, helper scripts,&#xA;and reference documentation for specific tasks.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;A commit flow with checks, generated files, partial staging rules, or project&#xA;specific policy deserves a skill. This one is a couple of words and one&#xA;constraint, so I choose to keep it simple. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: pi: prompt template or skill?&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/pi/&#34;&gt;#pi&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>terrateam: plan from drift issues
      </title>
      <link>https://perrotta.dev/2026/08/terrateam-plan-from-drift-issues/</link>
      <pubDate>Mon, 24 Aug 2026 18:38:39 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>terraform</category>
      <category>terrateam</category>
      <guid>https://perrotta.dev/2026/08/terrateam-plan-from-drift-issues/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: Terrateam opens drift reports as GitHub issues, but &lt;code&gt;terrateam plan&lt;/code&gt; only works on pull requests.&lt;/p&gt;&#xA;&lt;p&gt;The distinction is explicit in Terrateam&amp;rsquo;s &lt;a href=&#34;https://github.com/terrateamio/terrateam/blob/cc723447ff80023b8627df6f8719bfb38462cf87/code/src/terrat_vcs_service_github/terrat_vcs_service_github_ep_events3.ml#L550-L655&#34;&gt;issue comment handler&lt;/a&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-ocaml&#34;&gt;{ primary = Primary.{ number = pull_request_id; pull_request = Some _; _ }; _ };&#xA;&#xA;| Gw.Issue_comment_event.Issue_comment_created _ -&amp;gt;&#xA;    Logs.debug (fun m -&amp;gt; m &amp;#34;%s : NOOP : ISSUE_COMMENT_CREATED&amp;#34; request_id);&#xA;    Prmths.Counter.inc_one (Metrics.comment_events_total &amp;#34;noop&amp;#34;);&#xA;    Abbs_future_combinators.return_ok ()&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Calling the repository workflow directly was not an option either. Its work token comes from the Terrateam backend.&lt;/p&gt;&#xA;&lt;p&gt;So I kept Terrateam in charge and gave it the pull request it expects. A GitHub Actions relay checks that the source is an open Terrateam drift issue and that the commenter has repository write access. It then puts a comment-only Terraform file in each selected drift directory:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;markers = [&#xA;    InputGitTreeElement(&#xA;        path=f&amp;#34;{directory}/terrateam_issue_relay.tf&amp;#34;,&#xA;        mode=&amp;#34;100644&amp;#34;,&#xA;        type=&amp;#34;blob&amp;#34;,&#xA;        content=f&amp;#34;# Terrateam plan relay for drift issue #{issue_number}, comment {comment_id}.\n&amp;#34;,&#xA;    )&#xA;    for directory in directories&#xA;]&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That path change gives Terrateam a real dirspace without changing infrastructure. The relay opens a temporary draft PR and posts the original command on it:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;pull = repo.create_pull(&#xA;    base=repo.default_branch,&#xA;    head=branch,&#xA;    title=f&amp;#34;Terrateam plan relay for drift issue #{issue_number}&amp;#34;,&#xA;    body=relay_body(issue_number, comment_id, event[&amp;#34;comment&amp;#34;][&amp;#34;html_url&amp;#34;]),&#xA;    draft=True,&#xA;)&#xA;pull.as_issue().create_comment(command)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Terrateam uses its normal locks, hooks, credentials, and batching. Another workflow copies its comments back to the drift issue. &lt;code&gt;apply&lt;/code&gt; remains unsupported: an issue has no PR approval or review intent.&lt;/p&gt;&#xA;&lt;p&gt;The live test planned the requested directory and returned the result:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;## Plans :thumbsup:&#xA;&#xA;## Terrateam Plan Output :thumbsup:&#xA;&#xA;**Plan: 0 to add, 8 to change, 0 to destroy**&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The temporary PR ended where it should:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% gh pr view 6687 --json number,state,headRefName,title&#xA;{&amp;#34;headRefName&amp;#34;:&amp;#34;bot/terrateam-issue-relay/6663-5398113346&amp;#34;,&amp;#34;number&amp;#34;:6687,&amp;#34;state&amp;#34;:&amp;#34;CLOSED&amp;#34;,&amp;#34;title&amp;#34;:&amp;#34;Terrateam plan relay for drift issue #6663&amp;#34;}&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ./ci/run_python_tests.sh ci/terrateam_issue_relay&#xA;collected 19 items&#xA;tests/test_relay.py ...................                                  [100%]&#xA;============================== 19 passed in 1.45s ==============================&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A scheduled job deletes inactive relay branches after 30 hours. Terrateam still owns plan execution; the relay only translates the issue command into its existing PR protocol. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: terrateam: plan from drift issues&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/terraform/&#34;&gt;#terraform&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/terrateam/&#34;&gt;#terrateam&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>★ Books
      </title>
      <link>https://perrotta.dev/2026/08/books/</link>
      <pubDate>Mon, 24 Aug 2026 18:28:31 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bestof</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/08/books/</guid>
      <description>&lt;p&gt;♠ It&amp;rsquo;s about time: I added a &lt;a href=&#34;https://perrotta.dev/books/&#34;&gt;books&lt;/a&gt; page to this blog with a sample of&#xA;my favorites. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: Books&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bestof/&#34;&gt;#bestof&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>goodreads: a books page from a private shelf
      </title>
      <link>https://perrotta.dev/2026/08/goodreads-a-books-page-from-a-private-shelf/</link>
      <pubDate>Mon, 24 Aug 2026 16:39:40 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>meta</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/08/goodreads-a-books-page-from-a-private-shelf/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2025/03/goodreads/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: I wanted a &lt;code&gt;/books&lt;/code&gt; page with covers, but my Goodreads&#xA;shelf is private and the API has been dead since 2020.&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% curl -sS &amp;#34;https://www.goodreads.com/review/list_rss/7873832?shelf=read&amp;#34; \&#xA;    -o /tmp/rss.txt -w &amp;#34;%{http_code}\n&amp;#34;&#xA;401&#xA;% head -c 42 /tmp/rss.txt&#xA;Sorry, that person&amp;#39;s shelf is private&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The CSV export needs no auth, no key, no scraping — one click under &lt;em&gt;My Books →&#xA;Import/Export&lt;/em&gt;. It carries 1469 rows and everything worth rendering, except&#xA;covers:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% head -1 ~/Downloads/goodreads_library_export.csv | tr &amp;#39;,&amp;#39; &amp;#39;\n&amp;#39; | grep -ciE &amp;#34;cover|image&amp;#34;&#xA;0&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;So &lt;code&gt;ci/goodreads_to_books.py&lt;/code&gt; reads the CSV, keeps the five-star &lt;code&gt;read&lt;/code&gt; rows,&#xA;and writes &lt;code&gt;data/books.yaml&lt;/code&gt; for Hugo to render:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% just goodreads-import&#xA;ci/goodreads_to_books.py ~/Downloads/goodreads_library_export.csv&#xA;wrote 101 books in 2 categories to ~/Workspace/perrotta.dev/data/books.yaml (101 kept their category, series, note and cover; 101 covers, 0 fetched; 63 excluded)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;101 on display, 63 excluded: the 164 five-star rows, minus the textbooks and&#xA;language courses nobody needs a recommendation for.&lt;/p&gt;&#xA;&lt;p&gt;Covers come from each book&amp;rsquo;s public page — those stay readable even when the&#xA;shelf is not, and the &lt;code&gt;og:image&lt;/code&gt; points straight at the CDN:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% curl -sL &amp;#34;https://www.goodreads.com/book/show/22034&amp;#34; |&#xA;    grep -o &amp;#39;&amp;lt;meta property=&amp;#34;og:image&amp;#34; content=&amp;#34;[^&amp;#34;]*&amp;#34;&amp;#39;&#xA;&amp;lt;meta property=&amp;#34;og:image&amp;#34; content=&amp;#34;https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1394988109i/22034.jpg&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Which lands in the data file, one fetch per book, ever:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;      - title: The Godfather&#xA;        author: Mario Puzo&#xA;        year: 1969&#xA;        rating: 5&#xA;        url: https://www.goodreads.com/book/show/22034&#xA;        cover: https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1394988109i/22034.jpg&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That &amp;ldquo;ever&amp;rdquo; is the whole trick. Crawling 164 pages back to back gets an AWS WAF&#xA;challenge — HTTP 202 with an empty body — and it sticks around for a few&#xA;minutes:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;  Thinking, Fast and Slow: HTTP 202, 0 bytes&#xA;  Blindness: HTTP 202, 0 bytes&#xA;  Freakonomics &amp;#43; Superfreakonomics: HTTP 202, 0 bytes&#xA;giving up after 5 failures in a row; run again later to fetch the remaining 43 covers&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;So the importer treats a cover as a cache entry rather than a build step: three&#xA;seconds between requests, a save every ten covers, and a bail-out after five&#xA;consecutive failures. A blocked run keeps what it got, and the next run resumes:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;        book[&amp;#34;cover&amp;#34;] = fetch_cover(book)&#xA;        if book[&amp;#34;cover&amp;#34;]:&#xA;            fetched &amp;#43;= 1&#xA;            failures = 0&#xA;            if fetched % COVER_SAVE_EVERY == 0:&#xA;                save()&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Three runs, spaced by coffee, and every cover was in. Re-imports are now free —&#xA;&lt;code&gt;0 fetched&lt;/code&gt; above — and idempotent, which matters because the CSV is the source&#xA;of truth for titles and ratings, while the YAML owns the curation:&#xA;categories, series names, notes, an &lt;code&gt;excluded&lt;/code&gt; list for the books I don&amp;rsquo;t want&#xA;on display, and an &lt;code&gt;overrides&lt;/code&gt; list for when the shelved edition is not the one&#xA;worth linking:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;overrides:&#xA;  - id: &amp;#34;15779555&amp;#34;&#xA;    title: The Godfather&#xA;    url: https://www.goodreads.com/book/show/22034&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That entry is the Brazilian translation of &lt;em&gt;The Godfather&lt;/em&gt;. It is what I read,&#xA;but &lt;em&gt;O Poderoso Chefão&lt;/em&gt; is not what I would recommend.&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git --no-pager show --stat --oneline 7c8785dbb4&#xA;7c8785dbb4 books: add fiction favorites&#xA; data/books.yaml | 348 &amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&#xA; 1 file changed, 348 insertions(&amp;#43;)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Fiction first. Non-fiction is still sitting unstaged, which is its own kind of&#xA;review queue.&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;🤖 &lt;em&gt;Drafted with &lt;a href=&#34;https://github.com/thiagowfx/skills/blob/master/plugins/thiagowfx/skills/bloggify/SKILL.md&#34;&gt;&lt;code&gt;/bloggify&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt; ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: goodreads: a books page from a private shelf&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/meta/&#34;&gt;#meta&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>bloggify, revisited
      </title>
      <link>https://perrotta.dev/2026/08/bloggify-revisited/</link>
      <pubDate>Mon, 24 Aug 2026 01:21:57 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>serenity</category>
      <guid>https://perrotta.dev/2026/08/bloggify-revisited/</guid>
      <description>&lt;p&gt;♠ Yours truly, &lt;a href=&#34;https://perrotta.dev/2026/06/bloggify/&#34;&gt;more than two months ago&lt;/a&gt;:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;As of today, it&amp;rsquo;s not clear to me whether this approach will stick.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;Clearly this approach is here to stay: &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;bloggify&lt;/a&gt; (40+&#xA;posts at the time of this writing).&lt;/p&gt;&#xA;&lt;p&gt;I am starting to ponder whether to split &lt;code&gt;/bloggify&lt;/code&gt; posts into an &lt;a href=&#34;https://blog.fsck.com/agent-blog/&#34;&gt;Agent&#xA;Blog&lt;/a&gt;:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;These posts are written by my agents, not by me.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;We&amp;rsquo;ll see. ∎&lt;/p&gt;&#xA;&lt;p&gt;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: bloggify, revisited&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/ai/&#34;&gt;#ai&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/bloggify/&#34;&gt;#bloggify&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/serenity/&#34;&gt;#serenity&lt;/a&gt;&lt;/p&gt;</description>
    </item>
  </channel>
</rss>
