<?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>Bloggify on ¬ just serendipity 🍀</title>
    <link>https://perrotta.dev/</link>
    <description>Recent content in Bloggify 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>
    <lastBuildDate>Wed, 30 Sep 2026 11:10:01 +0200</lastBuildDate>
    <atom:link href="https://perrotta.dev/tags/bloggify/index.xml" rel="self" type="application/rss+xml" />
    <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>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>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>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>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>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>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>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>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>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>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>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>
    <item>
      <title>pi: statusline with GitHub PR link
      </title>
      <link>https://perrotta.dev/2026/08/pi-statusline-with-github-pr-link/</link>
      <pubDate>Mon, 24 Aug 2026 01:14:47 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>git</category>
      <category>pi</category>
      <guid>https://perrotta.dev/2026/08/pi-statusline-with-github-pr-link/</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 &lt;a href=&#34;https://www.npmjs.com/package/@narumitw/pi-github-pr&#34;&gt;&lt;code&gt;@narumitw/pi-github-pr&lt;/code&gt;&lt;/a&gt;&#xA;statusline entry reports much more than I care about:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;PR #123: checks failing (2), changes requested, 3 comments&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I want the clickable PR link and nothing else. Checks live in &lt;code&gt;gh pr checks&lt;/code&gt;,&#xA;comments live on GitHub, they are the source of truth.&lt;/p&gt;&#xA;&lt;p&gt;There&amp;rsquo;s no knob for it in this particular pi extension. The wording is hardcoded&#xA;in &lt;code&gt;formatCompactStatus()&lt;/code&gt;, and the single option the extension takes&#xA;(&lt;code&gt;refreshIntervalMs&lt;/code&gt;) is unreachable, because pi calls the default export with&#xA;&lt;code&gt;pi&lt;/code&gt; alone. Nothing lighter exists on the registry either — everything else is a&#xA;full footer replacement:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% for q in &amp;#34;pi-extension%20pull%20request&amp;#34; &amp;#34;pi-extension%20statusline&amp;#34;; do&#xA;    curl -s &amp;#34;https://registry.npmjs.org/-/v1/search?text=$q&amp;amp;size=25&amp;#34; |&#xA;      jq -r &amp;#39;.objects[].package | select(.name | test(&amp;#34;^(@[^/]&amp;#43;/)?pi-&amp;#34;)) | &amp;#34;\(.name) — \(.description)&amp;#34;&amp;#39;&#xA;  done | grep -iE &amp;#34;pull request|statusline|footer&amp;#34; | sort -u&#xA;@feniix/pi-statusline — Fixed two-line status footer for pi with model, thinking, context, git, token, worktree, and skill indicators&#xA;@narumitw/pi-github-pr — Pi extension that shows GitHub pull request review, checks, and comment status.&#xA;@narumitw/pi-statusline — Pi extension that replaces the footer with an information-rich statusline.&#xA;[...]&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Then we&amp;rsquo;ll simply fork it away: 65 lines of local extension replace the 600+ of&#xA;the extension:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typescript&#34;&gt;export function formatPrLink(stdout: string): string | undefined {&#xA;&#x9;let pr: { number?: unknown; url?: unknown; state?: unknown };&#xA;&#x9;try {&#xA;&#x9;&#x9;pr = JSON.parse(stdout);&#xA;&#x9;} catch {&#xA;&#x9;&#x9;return undefined;&#xA;&#x9;}&#xA;&#x9;if (pr.state !== &amp;#34;OPEN&amp;#34;) return undefined;&#xA;&#x9;if (typeof pr.number !== &amp;#34;number&amp;#34; || !Number.isFinite(pr.number)) return undefined;&#xA;&#x9;const label = `PR #${pr.number}`;&#xA;&#x9;return typeof pr.url === &amp;#34;string&amp;#34; ? osc8Link(pr.url, label) : label;&#xA;}&#xA;&#xA;export default function (pi: ExtensionAPI) {&#xA;&#x9;let request = 0;&#xA;&#xA;&#x9;const refresh = async (ctx: ExtensionContext) =&amp;gt; {&#xA;&#x9;&#x9;request &amp;#43;= 1;&#xA;&#x9;&#x9;const current = request;&#xA;&#x9;&#x9;let status: string | undefined;&#xA;&#x9;&#x9;try {&#xA;&#x9;&#x9;&#x9;const result = await pi.exec(&amp;#34;gh&amp;#34;, [&amp;#34;pr&amp;#34;, &amp;#34;view&amp;#34;, &amp;#34;--json&amp;#34;, &amp;#34;number,url,state&amp;#34;], {&#xA;&#x9;&#x9;&#x9;&#x9;cwd: ctx.cwd,&#xA;&#x9;&#x9;&#x9;&#x9;signal: ctx.signal,&#xA;&#x9;&#x9;&#x9;&#x9;timeout: GH_TIMEOUT_MS,&#xA;&#x9;&#x9;&#x9;});&#xA;&#x9;&#x9;&#x9;if (result.code === 0 &amp;amp;&amp;amp; !result.killed) status = formatPrLink(result.stdout);&#xA;&#x9;&#x9;} catch {&#xA;&#x9;&#x9;&#x9;status = undefined;&#xA;&#x9;&#x9;}&#xA;&#x9;&#x9;if (current === request &amp;amp;&amp;amp; !ctx.signal?.aborted) ctx.ui.setStatus(STATUS_KEY, status);&#xA;&#x9;};&#xA;&#xA;&#x9;pi.on(&amp;#34;session_start&amp;#34;, async (_event, ctx) =&amp;gt; refresh(ctx));&#xA;&#x9;pi.on(&amp;#34;agent_end&amp;#34;, async (_event, ctx) =&amp;gt; refresh(ctx));&#xA;&#x9;pi.on(&amp;#34;session_shutdown&amp;#34;, async (_event, ctx) =&amp;gt; {&#xA;&#x9;&#x9;request &amp;#43;= 1;&#xA;&#x9;&#x9;ctx.ui.setStatus(STATUS_KEY, undefined);&#xA;&#x9;});&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The &lt;code&gt;osc8Link()&lt;/code&gt; helper wraps the number in an OSC 8 hyperlink, so the entry is&#xA;clickable in the terminal (Cmd + click in Ghostty, for example):&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% node --experimental-strip-types -e &amp;#39;&#xA;const { formatPrLink } = await import(&amp;#34;/Users/thiago.perrotta/.pi/agent/extensions/github-pr-link.ts&amp;#34;);&#xA;const { execFileSync } = await import(&amp;#34;node:child_process&amp;#34;);&#xA;const out = execFileSync(&amp;#34;gh&amp;#34;, [&amp;#34;pr&amp;#34;, &amp;#34;view&amp;#34;, &amp;#34;14215&amp;#34;, &amp;#34;-R&amp;#34;, &amp;#34;cli/cli&amp;#34;, &amp;#34;--json&amp;#34;, &amp;#34;number,url,state&amp;#34;], { encoding: &amp;#34;utf8&amp;#34; });&#xA;console.log(&amp;#34;gh stdout:&amp;#34;, out.trim());&#xA;console.log(&amp;#34;status:&amp;#34;, JSON.stringify(formatPrLink(out)));&#xA;&amp;#39;&#xA;gh stdout: {&amp;#34;number&amp;#34;:14215,&amp;#34;state&amp;#34;:&amp;#34;OPEN&amp;#34;,&amp;#34;url&amp;#34;:&amp;#34;https://github.com/cli/cli/pull/14215&amp;#34;}&#xA;status: &amp;#34;\u001b]8;;https://github.com/cli/cli/pull/14215\u0007PR #14215\u001b]8;;\u0007&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Dropping the counters also dropped the second network call: the package pairs&#xA;every &lt;code&gt;gh pr view&lt;/code&gt; with a GraphQL query for comment and review totals, and polls&#xA;every 60 seconds on top of session start, branch changes, and agent turns. One&#xA;&lt;code&gt;gh pr view&lt;/code&gt; on session start and after each turn is enough. A branch switch&#xA;lands on the next turn end anyway, because switching branches means running&#xA;&lt;code&gt;git checkout&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;Every failure path — no PR, merged PR, missing &lt;code&gt;gh&lt;/code&gt;, unauthenticated &lt;code&gt;gh&lt;/code&gt; — just&#xA;clears the entry:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% node --experimental-strip-types --test agent/extensions/tests/github-pr-link.test.ts&#xA;✔ formatPrLink links open PRs and hides everything else (0.601333ms)&#xA;✔ osc8Link rejects non-http schemes (0.079ms)&#xA;✔ session start shows the PR link (0.526625ms)&#xA;✔ agent end clears the entry when gh fails (0.094167ms)&#xA;✔ agent end clears the entry when gh is missing (0.093125ms)&#xA;✔ aborted turn keeps the previous entry (0.466084ms)&#xA;✔ session shutdown clears the entry (0.104292ms)&#xA;✔ a stale refresh does not overwrite a newer one (0.165791ms)&#xA;ℹ pass 8&#xA;ℹ fail 0&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The whole swap:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% pi uninstall npm:@narumitw/pi-github-pr&#xA;Removing npm:@narumitw/pi-github-pr...&#xA;Removed npm:@narumitw/pi-github-pr&#xA;&#xA;% git --no-pager show --stat --oneline HEAD&#xA;4dadda2 pi: replace pi-github-pr with a link-only extension&#xA; pi/.pi/README.md                                   |   4 &amp;#43;-&#xA; pi/.pi/agent/extensions/github-pr-link.ts          |  65 &amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&#xA; .../agent/extensions/tests/github-pr-link.test.ts  | 139 &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; pi/.pi/agent/settings.json                         |   3 &amp;#43;-&#xA; 4 files changed, 207 insertions(&amp;#43;), 4 deletions(-)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Files under &lt;code&gt;~/.pi/agent/extensions/*.ts&lt;/code&gt; are auto-discovered, so &lt;code&gt;/reload&lt;/code&gt;&#xA;picks it up without restarting the session. ∎&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: statusline with GitHub PR link&#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/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>prometheus: a 117GB WAL that could not truncate
      </title>
      <link>https://perrotta.dev/2026/08/prometheus-a-117gb-wal-that-could-not-truncate/</link>
      <pubDate>Sat, 22 Aug 2026 23:35:00 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/08/prometheus-a-117gb-wal-that-could-not-truncate/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/azure-disk-resize-needs-the-pvc-patch-too/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;With the volume expanded, the next failure surfaced. The pod came up, served&#xA;for 14 minutes, then hit a different wall:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring get pod prometheus-...-prometheus-0 -o json | \&#xA;    jq &amp;#39;.status.containerStatuses[] | select(.name==&amp;#34;prometheus&amp;#34;) | .lastState.terminated&amp;#39;&#xA;{&#xA;  &amp;#34;reason&amp;#34;: &amp;#34;OOMKilled&amp;#34;,&#xA;  &amp;#34;exitCode&amp;#34;: 137,&#xA;  &amp;#34;startedAt&amp;#34;: &amp;#34;2026-08-22T20:35:12Z&amp;#34;,&#xA;  &amp;#34;finishedAt&amp;#34;: &amp;#34;2026-08-22T20:49:29Z&amp;#34;&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Not disk this time — 129GB was free. The WAL was the problem:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% du -sh /prometheus/prometheus-db/wal&#xA;117.7G&#x9;/prometheus/prometheus-db/wal&#xA;% ls /prometheus/prometheus-db/wal | wc -l&#xA;1062&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A healthy WAL holds about two hours. This one spanned two days, because WAL&#xA;truncation happens at head compaction, and head compaction was OOMKilling&#xA;before it could commit. I caught it mid-attempt once:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ls -d /prometheus/prometheus-db/wal/checkpoint.*&#xA;/prometheus/prometheus-db/wal/checkpoint.00188884&#xA;/prometheus/prometheus-db/wal/checkpoint.00189585.tmp&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That &lt;code&gt;.tmp&lt;/code&gt; never landed. The next check, it was gone and the old checkpoint&#xA;from two days earlier was still the newest one. Each cycle ingested a little&#xA;more than it compacted, so the head grew, and the OOM arrived sooner. 205&#xA;restarts of no net progress.&lt;/p&gt;&#xA;&lt;p&gt;The memory limit had no room to grow either:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;      resources:&#xA;        limits:&#xA;          memory: 14Gi&#xA;        requests:&#xA;          cpu: 1&#xA;          memory: 11Gi&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;14Gi against a &lt;code&gt;Standard_D4ps_v6&lt;/code&gt; with 15.0Gi allocatable. Raising it makes the&#xA;pod unschedulable.&lt;/p&gt;&#xA;&lt;p&gt;So: delete the WAL. Thanos had already shipped every local block, which is what&#xA;made this cheap:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% cat /prometheus/prometheus-db/thanos.shipper.json&#xA;{&#xA;&#x9;&amp;#34;version&amp;#34;: 1,&#xA;&#x9;&amp;#34;uploaded&amp;#34;: [&#xA;&#x9;&#x9;&amp;#34;01M0NKKGWJVKD3R84S90WW9R7G&amp;#34;,&#xA;&#x9;&#x9;&amp;#34;01M0NMBBC2Y31KMSJRA7VSGYXA&amp;#34;,&#xA;&#x9;&#x9;&amp;#34;01M0NMDM1XRJ0P1MMRBKF5HDV0&amp;#34;&#xA;&#x9;]&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Scaling the StatefulSet down does not work — prometheus-operator owns it and put&#xA;the replica back within two minutes. The &lt;code&gt;Prometheus&lt;/code&gt; CR is the real lever:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring patch prometheus prometheus-kube-prometheus-prometheus \&#xA;    --type merge -p &amp;#39;{&amp;#34;spec&amp;#34;:{&amp;#34;replicas&amp;#34;:0}}&amp;#39;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Then a scratch pod on the PVC, where I nearly deleted nothing at all:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring exec wal-cleanup -- ls /prometheus&#xA;lost&amp;#43;found&#xA;prometheus-db&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The StatefulSet mounts that volume with a &lt;code&gt;subPath&lt;/code&gt;. Deleting &lt;code&gt;/prometheus/wal&lt;/code&gt;&#xA;would have removed nothing, and the restart would have looked like a fix.&#xA;The data is one level down:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring exec wal-cleanup -- \&#xA;    find /prometheus/prometheus-db/wal /prometheus/prometheus-db/chunks_head -mindepth 1 -delete&#xA;% kubectl -n monitoring exec wal-cleanup -- df -h /prometheus | tail -1&#xA;/dev/sda                250.9G      3.8G    247.1G   2% /prometheus&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Replay after that:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;total_replay_duration=280.16µs&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Down from ten minutes. Cost: five hours of local samples, everything older still&#xA;queryable through Thanos.&lt;/p&gt;&#xA;&lt;p&gt;Two independent limits, hit in sequence: the volume, then the memory ceiling&#xA;that kept compaction from ever committing. The remaining one is a limit sized at&#xA;93% of the node it runs on, and that needs a bigger node pool. ∎&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: prometheus: a 117GB WAL that could not truncate&#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/kubernetes/&#34;&gt;#kubernetes&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>azure disk: resize needs the PVC patch too
      </title>
      <link>https://perrotta.dev/2026/08/azure-disk-resize-needs-the-pvc-patch-too/</link>
      <pubDate>Sat, 22 Aug 2026 22:50:20 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>dev</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/08/azure-disk-resize-needs-the-pvc-patch-too/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: a Prometheus pod crash-looped on a full 128GiB volume. I&#xA;resized the Azure-managed disk to 256Gi in the Azure Portal (ClickOps) to no&#xA;avail.&lt;/p&gt;&#xA;&lt;p&gt;The disk &lt;em&gt;really&lt;/em&gt; was bigger:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% az disk show -g my-cluster-k8s-nodes \&#xA;    -n pvc-86ac2c66-fb67-43c3-84b0-7209f3a23bd3 -o json | \&#xA;    jq &amp;#39;{diskSizeGB, diskState, provisioningState}&amp;#39;&#xA;{&#xA;  &amp;#34;diskSizeGB&amp;#34;: 256,&#xA;  &amp;#34;diskState&amp;#34;: &amp;#34;Attached&amp;#34;,&#xA;  &amp;#34;provisioningState&amp;#34;: &amp;#34;Succeeded&amp;#34;&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Kubernetes disagreed though (well, it didn&amp;rsquo;t know about the resize yet):&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring get pvc prometheus-...-prometheus-0 -o json | \&#xA;    jq &amp;#39;{spec: .spec.resources.requests, status: .status.capacity, alloc: .status.allocatedResources}&amp;#39;&#xA;{&#xA;  &amp;#34;spec&amp;#34;: {&amp;#34;storage&amp;#34;: &amp;#34;128Gi&amp;#34;},&#xA;  &amp;#34;status&amp;#34;: {&amp;#34;storage&amp;#34;: &amp;#34;128Gi&amp;#34;},&#xA;  &amp;#34;alloc&amp;#34;: null&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;allocatedResources: null&lt;/code&gt; means no resize was ever requested. Growing the disk&#xA;in Azure doesn&amp;rsquo;t tell the CSI driver anything, so nobody ran &lt;code&gt;resize2fs&lt;/code&gt;, and&#xA;the filesystem stayed exactly where it was:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring exec prometheus-...-prometheus-0 -c thanos-sidecar -- df -h /prometheus&#xA;Filesystem                Size      Used Available Use% Mounted on&#xA;/dev/sda                124.9G    123.0G      1.9G  98% /prometheus&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The fix is to &lt;strong&gt;patch the PVC&lt;/strong&gt; so the control plane catches up.&#xA;&lt;code&gt;disk.csi.azure.com&lt;/code&gt; sees the block device is already large enough, skips the&#xA;Azure API call, and goes straight to the filesystem:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring patch pvc prometheus-...-prometheus-0 \&#xA;    -p &amp;#39;{&amp;#34;spec&amp;#34;:{&amp;#34;resources&amp;#34;:{&amp;#34;requests&amp;#34;:{&amp;#34;storage&amp;#34;:&amp;#34;256Gi&amp;#34;}}}}&amp;#39;&#xA;persistentvolumeclaim/prometheus-...-prometheus-0 patched&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n monitoring exec prometheus-...-prometheus-0 -c thanos-sidecar -- df -h /prometheus&#xA;Filesystem                Size      Used Available Use% Mounted on&#xA;/dev/sda                250.9G    124.6G    126.4G  50% /prometheus&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;42MB free became 126.4GB, and the pod went &lt;code&gt;3/3 Running&lt;/code&gt; after 203 crashes.&lt;/p&gt;&#xA;&lt;p&gt;The lesson: patch the PVC and then let CSI follow up.&lt;/p&gt;&#xA;&lt;p&gt;Resizing in Azure first just creates a split state where the volume is bigger&#xA;but nothing can use it. ∎&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: azure disk: resize needs the PVC patch too&#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/kubernetes/&#34;&gt;#kubernetes&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>anki: migrate English notes to TTS note types
      </title>
      <link>https://perrotta.dev/2026/08/anki-migrate-english-notes-to-tts-note-types/</link>
      <pubDate>Sat, 22 Aug 2026 13:36:25 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>anki</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>pkm</category>
      <guid>https://perrotta.dev/2026/08/anki-migrate-english-notes-to-tts-note-types/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/04/anki-flashcard-generation-with-claude-code/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: migrate my English 🇬🇧 deck to dedicated note types with&#xA;British English text-to-speech, without adding reverse cards.&lt;/p&gt;&#xA;&lt;p&gt;I created &lt;code&gt;English Language Card 🇬🇧&lt;/code&gt; with four fields (&lt;code&gt;Front&lt;/code&gt;, &lt;code&gt;Front Example&lt;/code&gt;,&#xA;&lt;code&gt;Back&lt;/code&gt;, and &lt;code&gt;Striked&lt;/code&gt;) and only one card template:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;{{Front}} {{tts en_GB:Front}}&#xA;&#xA;{{#Front Example}}&#xA;&amp;lt;br&amp;gt;&#xA;&amp;lt;i&amp;gt;{{Front Example}}&amp;lt;/i&amp;gt; {{tts en_GB:Front Example}}&#xA;{{/Front Example}}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;English Grammatik Cloze 🇬🇧&lt;/code&gt; uses the same TTS setup for cloze notes:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;{{cloze:Text}} {{tts en_GB:Text}}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;AnkiConnect could create both types, but did not expose a &lt;code&gt;changeNoteType&lt;/code&gt;&#xA;action. I closed Anki and used its own Python package instead. Before changing&#xA;the collection, I exported the English deck and copied the SQLite database:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;4.8M pre-english-note-type-migration-2026-08-22-13.27.44.apkg&#xA;9.9M pre-english-note-type-migration-2026-08-22-13.29.08.collection.anki2&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The field and template maps did the migration in place:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;col.models.change(basic, groups[&amp;#34;Basic&amp;#34;], language, {0: 0, 1: 2}, {0: 0})&#xA;col.models.change(cloze, groups[&amp;#34;Cloze&amp;#34;], english_cloze, {0: 0, 1: 1}, None)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I rehearsed the migration against the database copy before running it for real:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% PYTHONPATH=/Applications/Anki.app/Contents/Resources/app_packages \&#xA;  /opt/homebrew/bin/python3.13 /tmp/anki_migrate_english.py \&#xA;  ~/Library/Application\ Support/Anki2/User\ 1/collection.anki2&#xA;{&#xA;  &amp;#34;notes&amp;#34;: 1089,&#xA;  &amp;#34;cards_before&amp;#34;: 1096,&#xA;  &amp;#34;cards_after&amp;#34;: 1089,&#xA;  &amp;#34;language_notes&amp;#34;: 1086,&#xA;  &amp;#34;cloze_notes&amp;#34;: 3,&#xA;  &amp;#34;removed_reverse_cards&amp;#34;: 7,&#xA;  &amp;#34;integrity&amp;#34;: &amp;#34;ok&amp;#34;&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The script also checked every mapped field, retained card ID, and review-log&#xA;count. After a full upload to AnkiWeb, a normal sync passed.&lt;/p&gt;&#xA;&lt;p&gt;No reverse cards, and all English notes can now speak for themselves (pun&#xA;intended!). ∎&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: anki: migrate English notes to TTS note types&#34;&gt;email&lt;/a&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&#34;https://perrotta.dev/tags/anki/&#34;&gt;#anki&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;/p&gt;</description>
    </item>
    <item>
      <title>hugo: tag-based post connections
      </title>
      <link>https://perrotta.dev/2026/08/hugo-tag-based-post-connections/</link>
      <pubDate>Sat, 22 Aug 2026 13:29:06 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>coding</category>
      <category>dev</category>
      <category>meta</category>
      <guid>https://perrotta.dev/2026/08/hugo-tag-based-post-connections/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/llm-devise-a-plan/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: post connections showed only direct links, while posts&#xA;with the same tags remained disconnected in the graph of this blog.&lt;/p&gt;&#xA;&lt;p&gt;I added a second kind of edge. The pre-computation picks up to two posts with&#xA;the &lt;strong&gt;most shared tags&lt;/strong&gt;, breaks ties by date, and excludes direct links:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;def compute_tag_related_posts(&#xA;    post: dict,&#xA;    all_posts: list[dict],&#xA;    outlinks_set: set[str],&#xA;    backlinks_set: set[str],&#xA;    limit: int = 2,&#xA;) -&amp;gt; list[str]:&#xA;    &amp;#34;&amp;#34;&amp;#34;Compute tag connections, excluding posts with direct links.&amp;#34;&amp;#34;&amp;#34;&#xA;    post_tags = set(post[&amp;#34;tags&amp;#34;])&#xA;    if not post_tags:&#xA;        return []&#xA;&#xA;    direct_ids = outlinks_set | backlinks_set&#xA;    scored = []&#xA;    for other in all_posts:&#xA;        other_id = other[&amp;#34;id&amp;#34;]&#xA;        if other_id == post[&amp;#34;id&amp;#34;] or other_id in direct_ids:&#xA;            continue&#xA;&#xA;        shared_tags = post_tags &amp;amp; set(other[&amp;#34;tags&amp;#34;])&#xA;        if shared_tags:&#xA;            scored.append((other_id, len(shared_tags), other[&amp;#34;date&amp;#34;]))&#xA;&#xA;    scored.sort(key=lambda item: (item[1], item[2], item[0]), reverse=True)&#xA;    return [item[0] for item in scored[:limit]]&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Direct links remain solid and prominent. Shared-tag links use smaller arrows&#xA;with dashed, muted edges:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-css&#34;&gt;.mini-graph .mini-graph-direct-edge {&#xA;  fill: none;&#xA;  stroke: var(--link-color);&#xA;  stroke-width: 2.25;&#xA;  stroke-opacity: 0.9;&#xA;}&#xA;&#xA;.mini-graph .mini-graph-tag-edge {&#xA;  fill: none;&#xA;  stroke: var(--color-gray);&#xA;  stroke-width: 1;&#xA;  stroke-dasharray: 4 4;&#xA;  stroke-opacity: 0.65;&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The original &lt;a href=&#34;https://perrotta.dev/2026/07/anki-archive-a-deck/&#34;&gt;Anki post&lt;/a&gt; now gets&#xA;one direct link and two tag connections:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-json&#34;&gt;{&#xA;  &amp;#34;outlinks&amp;#34;: [&amp;#34;2024-12-23-anki-workflow&amp;#34;],&#xA;  &amp;#34;tag_related&amp;#34;: [&#xA;    &amp;#34;2026-04-15-anki-flashcards-with-claude-code&amp;#34;,&#xA;    &amp;#34;2026-04-13-anki-api-access&amp;#34;&#xA;  ]&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% python3 ci/test_precompute_links.py&#xA;..&#xA;----------------------------------------------------------------------&#xA;Ran 2 tests in 0.000s&#xA;&#xA;OK&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;There&amp;rsquo;s a limit of 2 shared tag post edges for the sake of not visually&#xA;polluting the presentation.&lt;/p&gt;&#xA;&lt;p&gt;I&amp;rsquo;ve been loving how this little blog is evolving into my beloved (public)&#xA;digital garden. ∎&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: hugo: tag-based post connections&#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/coding/&#34;&gt;#coding&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>kargo: notify when a soak completes
      </title>
      <link>https://perrotta.dev/2026/08/kargo-notify-when-a-soak-completes/</link>
      <pubDate>Fri, 21 Aug 2026 18:38:23 +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/kargo-notify-when-a-soak-completes/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/kargo-cool-down-upstream-releases-with-a-holding-stage/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: notify a Slack channel whenever Freight finishes soaking in&#xA;an upstream Kargo Stage, without promoting it to the next environment.&lt;/p&gt;&#xA;&lt;p&gt;A soak does not finish a process. Once &lt;code&gt;requiredSoakTime&lt;/code&gt; elapses, Kargo simply&#xA;makes the Freight available downstream. There is no soak-complete event in the&#xA;&lt;a href=&#34;https://docs.kargo.io/user-guide/reference-docs/events/event-reference&#34;&gt;Kargo event list&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Worakround&lt;/strong&gt;: I made availability start a notification-only promotion. Relevant excerpt,&#xA;with variable values omitted:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;metadata:&#xA;  name: notify-remaining-gardens-ready&#xA;spec:&#xA;  requestedFreight:&#xA;    - origin:&#xA;        kind: Warehouse&#xA;        name: argocd&#xA;      sources:&#xA;        stages:&#xA;          - staging-gardens&#xA;        requiredSoakTime: 48h0m0s&#xA;  promotionTemplate:&#xA;    spec:&#xA;      steps:&#xA;        - uses: http&#xA;          config:&#xA;            method: POST&#xA;            url: https://slack.com/api/chat.postMessage&#xA;            responseContentType: application/json&#xA;            successExpression: response.status == 200 &amp;amp;&amp;amp; response.body.ok == true&#xA;            failureExpression: response.status != 200 || response.body.ok != true&#xA;            body: |-&#xA;              ${{ quote({&#xA;                &amp;#34;channel&amp;#34;: vars.slackChannel,&#xA;                &amp;#34;username&amp;#34;: &amp;#34;Kargo&amp;#34;,&#xA;                &amp;#34;icon_emoji&amp;#34;: &amp;#34;:kargo:&amp;#34;,&#xA;                &amp;#34;unfurl_links&amp;#34;: false,&#xA;                &amp;#34;text&amp;#34;: &amp;#34;:white_check_mark: *argocd*: chart &amp;#34; &amp;#43; chartFrom(vars.chartRepo, &amp;#34;argocd&amp;#34;).Version&#xA;                  &amp;#43; &amp;#34; completed 48h of soak in staging-gardens and is ready for promotion to remaining-gardens.&amp;#34;&#xA;                  &amp;#43; &amp;#34;\nFreight: `&amp;#34; &amp;#43; ctx.targetFreight.name &amp;#43; &amp;#34;`&amp;#34;&#xA;              })&#xA;              }}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&amp;hellip;wherein remaining-gardens = prod.&lt;/p&gt;&#xA;&lt;p&gt;Only the notification Stage auto-promotes:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;promotionPolicies:&#xA;  - stageSelector:&#xA;      name: notify-remaining-gardens-ready&#xA;    autoPromotionEnabled: true&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Once the HTTP step succeeds, the Freight occupies this Stage and does not send&#xA;again. The real downstream Stage remains human-gated. A Slack API error fails&#xA;only the notification Promotion, which can be retried.&lt;/p&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;This uses Kargo&amp;rsquo;s generic &lt;a href=&#34;https://docs.kargo.io/user-guide/reference-docs/promotion-steps/http&#34;&gt;&lt;code&gt;http&lt;/code&gt;&#xA;step&lt;/a&gt; as&#xA;the notification transport and Kargo itself as the timer.&lt;/p&gt;&#xA;&lt;p&gt;There&amp;rsquo;s no need to enroll into Akuity Platform ($$) just for the sake of&#xA;&lt;a href=&#34;https://docs.kargo.io/user-guide/reference-docs/promotion-steps/send-message&#34;&gt;&lt;code&gt;send-message&lt;/code&gt;&lt;/a&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: notify when a soak completes&#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>★ kubernetes: force-refresh a cached multi-architecture image
      </title>
      <link>https://perrotta.dev/2026/08/kubernetes-force-refresh-a-cached-multi-architecture-image/</link>
      <pubDate>Wed, 19 Aug 2026 11:22:26 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>argocd</category>
      <category>aws</category>
      <category>bestof</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>docker</category>
      <category>kubernetes</category>
      <guid>https://perrotta.dev/2026/08/kubernetes-force-refresh-a-cached-multi-architecture-image/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2024/12/skopeo-operate-container-images-and-registries/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: a Kubernetes ARM64 node kept running an AMD64 image after&#xA;I replaced the registry tag with a multi-architecture image via &lt;code&gt;skopeo&lt;/code&gt;, due to&#xA;image caching.&lt;/p&gt;&#xA;&lt;p&gt;I first mirrored only the AMD64 variant into AWS ECR (note: no &lt;code&gt;--all&lt;/code&gt;):&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% skopeo sync \&#xA;  --dest-creds &amp;#34;AWS:$(aws ecr get-login-password --region cn-north-1)&amp;#34; \&#xA;  --src docker --dest docker \&#xA;  --override-os linux --override-arch amd64 \&#xA;  quay.io/argoproj/argocd:v3.5.0 \&#xA;  &amp;#34;$REGISTRY/quay.io/argoproj&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That image failed on the ARM64 node:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;exec /usr/local/bin/argocd: exec format error&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The source has four platform manifests, so I replaced the ECR tag with the full&#xA;manifest list (&lt;code&gt;-all&lt;/code&gt;&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;https://perrotta.dev/2026/08/kubernetes-force-refresh-a-cached-multi-architecture-image/#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;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% skopeo sync \&#xA;  --all \&#xA;  --dest-creds &amp;#34;AWS:$(aws ecr get-login-password --region cn-north-1)&amp;#34; \&#xA;  --src docker --dest docker \&#xA;  quay.io/argoproj/argocd:v3.5.0 \&#xA;  &amp;#34;$REGISTRY/quay.io/argoproj&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;linux  amd64    sha256:521d6b62ecd0434c9cc6e9242a74f0e1137bb8fc0026b2c483ea88f3f17e725d&#xA;linux  arm64    sha256:8a14921f7e5dc3408111714f05bff68e337d459653c45f0aea738330d156079b&#xA;linux  s390x    sha256:28743e713eabeeaacc103d177fc0b651b564d10309839077b0381ca980055c7d&#xA;linux  ppc64le  sha256:1f9e154e0eef6df29b75948b0b54dfc8ec490c657100df11a24c34197b9beb3a&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The sync is complete.&lt;/p&gt;&#xA;&lt;p&gt;Deleting the failed pod did not help though. Its replacement still used the&#xA;cached AMD64 child manifest:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;pod: pod/argocd-redis-secret-init-qgnqr&#xA;node: ip-10-25-54-66.cn-north-1.compute.internal&#xA;arch: arm64&#xA;image ID: $REGISTRY/quay.io/argoproj/argocd@sha256:521d6b62ecd0434c9cc6e9242a74f0e1137bb8fc0026b2c483ea88f3f17e725d&#xA;pull policy: IfNotPresent&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The Job pod template is immutable, so changing its pull policy in place (to&#xA;&lt;code&gt;Always&lt;/code&gt;) also failed. Instead, I created a temporary pod on the same node. It&#xA;forced a pull and ran a harmless command that proved the selected binary can&#xA;be executed:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;apiVersion: v1&#xA;kind: Pod&#xA;metadata:&#xA;  name: argocd-image-refresh&#xA;  namespace: argocd&#xA;spec:&#xA;  nodeName: ip-10-0-0-42.cn-north-1.compute.internal&#xA;  restartPolicy: Never&#xA;  tolerations:&#xA;    - key: arch&#xA;      operator: Exists&#xA;      effect: NoSchedule&#xA;    - key: arch&#xA;      operator: Exists&#xA;      effect: NoExecute&#xA;  containers:&#xA;    - name: refresh&#xA;      image: $REGISTRY/quay.io/argoproj/argocd:v3.5.0&#xA;      imagePullPolicy: Always  # This is the important part!&#xA;      command: [&amp;#34;/usr/local/bin/argocd&amp;#34;]&#xA;      args: [&amp;#34;version&amp;#34;, &amp;#34;--client&amp;#34;]&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;NAME                   READY   STATUS      RESTARTS&#xA;argocd-image-refresh   0/1     Completed   0&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The node cache now pointed at the multi-architecture manifest list:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;$REGISTRY/quay.io/argoproj/argocd@sha256:c298cedbaeb31532ba8d4e9904eba9e4987e067293fbd86400c5194e78f743d5&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;I removed the temporary pod and the failed Job pod. The Job controller created&#xA;a replacement, and the ARM64 binary started normally.&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% kubectl -n argocd delete pod argocd-image-refresh&#xA;% kubectl -n argocd delete pod -l job-name=argocd-redis-secret-init&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;Why isn&amp;rsquo;t this the default?!&amp;#160;&lt;a href=&#34;https://perrotta.dev/2026/08/kubernetes-force-refresh-a-cached-multi-architecture-image/#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;— § —&lt;/p&gt;&lt;p&gt;Reply via &lt;a href=&#34;mailto:serendipity@perrotta.dev?subject=Reply to: kubernetes: force-refresh a cached multi-architecture image&#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/aws/&#34;&gt;#aws&lt;/a&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/dev/&#34;&gt;#dev&lt;/a&gt; &lt;a href=&#34;https://perrotta.dev/tags/docker/&#34;&gt;#docker&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>kargo: cool down upstream releases with a holding stage
      </title>
      <link>https://perrotta.dev/2026/08/kargo-cool-down-upstream-releases-with-a-holding-stage/</link>
      <pubDate>Wed, 19 Aug 2026 01:36:27 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>argocd</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>kubernetes</category>
      <category>security</category>
      <guid>https://perrotta.dev/2026/08/kargo-cool-down-upstream-releases-with-a-holding-stage/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: wait before opening an upstream Helm chart upgrade PR,&#xA;without putting a timer in the PR workflow.&lt;/p&gt;&#xA;&lt;p&gt;In other words: implement &lt;a href=&#34;https://blog.yossarian.net/2025/12/13/cooldowns-redux&#34;&gt;dependency&#xA;cooldowns&lt;/a&gt;&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a href=&#34;https://perrotta.dev/2026/08/kargo-cool-down-upstream-releases-with-a-holding-stage/#fn:1&#34; class=&#34;footnote-ref&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt; in Kargo.&lt;/p&gt;&#xA;&lt;p&gt;A &lt;a href=&#34;https://kargo.io/&#34;&gt;Kargo&lt;/a&gt; &lt;code&gt;Warehouse&lt;/code&gt; makes newly discovered &lt;code&gt;Freight&lt;/code&gt;&#xA;immediately available to a Stage that accepts it directly:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Warehouse ──immediate──&amp;gt; open-upgrade-pr ──&amp;gt; PR&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Kargo already has&#xA;&lt;a href=&#34;https://docs.kargo.io/user-guide/how-to-guides/verification/#soak-times&#34;&gt;&lt;code&gt;requiredSoakTime&lt;/code&gt;&lt;/a&gt;,&#xA;but only between Stages. It cannot delay a direct Warehouse-to-Stage promotion.&#xA;So add a Stage whose promotion deliberately does nothing:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;apiVersion: kargo.akuity.io/v1alpha1&#xA;kind: Stage&#xA;metadata:&#xA;  name: hold-upstream-release&#xA;spec:&#xA;  requestedFreight:&#xA;    - origin:&#xA;        kind: Warehouse&#xA;        name: external-secrets-upstream&#xA;      sources:&#xA;        direct: true&#xA;  promotionTemplate:&#xA;    spec:&#xA;      steps:&#xA;        - uses: compose-output&#xA;          config:&#xA;            state: holding&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The PR Stage accepts Freight from that holding Stage and starts only after the&#xA;cool-off period:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-yaml&#34;&gt;spec:&#xA;  requestedFreight:&#xA;    - origin:&#xA;        kind: Warehouse&#xA;        name: external-secrets-upstream&#xA;      sources:&#xA;        stages:&#xA;          - hold-upstream-release&#xA;        requiredSoakTime: 72h0m0s&#xA;  promotionTemplate:&#xA;    # clone, update chart dependency, push, and open PR&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Warehouse ──&amp;gt; hold-upstream-release ──72h──&amp;gt; open-upgrade-pr ──&amp;gt; PR&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The holding Stage must auto-promote, as must the PR Stage after its soak. A new&#xA;release replaces the current Freight in the holding Stage. If the old release&#xA;did not complete 72 hours, it does not advance. This makes the hold a stability&#xA;window: only a release that remains current for three days opens a PR.&lt;/p&gt;&#xA;&lt;p&gt;Explicit Freight approval remains the escape hatch because it bypasses the soak. ∎&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;cooldown or cool-off?&amp;#160;&lt;a href=&#34;https://perrotta.dev/2026/08/kargo-cool-down-upstream-releases-with-a-holding-stage/#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: kargo: cool down upstream releases with a holding stage&#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;a href=&#34;https://perrotta.dev/tags/security/&#34;&gt;#security&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>git: intent-to-add breaks autostash
      </title>
      <link>https://perrotta.dev/2026/08/git-intent-to-add-breaks-autostash/</link>
      <pubDate>Wed, 19 Aug 2026 01:09:07 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>coding</category>
      <category>git</category>
      <guid>https://perrotta.dev/2026/08/git-intent-to-add-breaks-autostash/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: &lt;code&gt;git pull&lt;/code&gt; could not autostash files added with&#xA;&lt;code&gt;git add -N&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;I had local work and was nine commits behind &lt;code&gt;origin/master&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git pull --force&#xA;Updating 266a5af537..07e4551075&#xA;error: Entry &amp;#39;ci/goodreads_to_reading.py&amp;#39; not uptodate. Cannot merge.&#xA;Cannot save the current worktree state&#xA;fatal: Cannot autostash&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;--force&lt;/code&gt; does not replace local worktree files. My global Git configuration&#xA;makes &lt;code&gt;git pull&lt;/code&gt; run &lt;code&gt;rebase.autostash&lt;/code&gt;, which found an intent-to-add entry:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git status --porcelain=v2&#xA;1 .M N... 100755 100755 100755 fcc319233ddf8e000ad2f735dbc2b0d760f467e5 fcc319233ddf8e000ad2f735dbc2b0d760f467e5 Justfile&#xA;1 .A N... 000000 000000 100755 0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 ci/goodreads_to_reading.py&#xA;1 .M N... 100644 100644 100644 2787100c3968766ea3a84e7c28e28b5046d1da32 2787100c3968766ea3a84e7c28e28b5046d1da32 config/_default/config.yml&#xA;1 .A N... 000000 000000 100644 0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 content/reading.md&#xA;1 .A N... 000000 000000 100644 0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 data/reading.yaml&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The &lt;code&gt;.A&lt;/code&gt; records have an empty index object (&lt;code&gt;0000000&lt;/code&gt;), so Git has tracked&#xA;path metadata without file content to stash.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;git reset&lt;/code&gt; removes intent-to-add entries and leaves worktree files alone:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git reset&#xA;% git pull&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: intent-to-add breaks autostash&#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/coding/&#34;&gt;#coding&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>pi: handing off to a focused session
      </title>
      <link>https://perrotta.dev/2026/08/pi-handing-off-to-a-focused-session/</link>
      <pubDate>Wed, 19 Aug 2026 01:05:33 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>bloggify</category>
      <category>coding</category>
      <category>dev</category>
      <category>pi</category>
      <guid>https://perrotta.dev/2026/08/pi-handing-off-to-a-focused-session/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/pi-fork-vs-clone-sessions/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;: This was a failed experiment.&#xA;I am including it here anyway, for completeness.&lt;/p&gt;&#xA;&lt;h2 id=&#34;handoff&#34;&gt;&#xA;  Handoff&#xA;  &lt;a class=&#34;heading-anchor&#34; href=&#34;https://perrotta.dev/2026/08/pi-handing-off-to-a-focused-session/#handoff&#34; aria-label=&#34;Link to this section&#34;&gt;#&lt;/a&gt;&#xA;&lt;/h2&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: move a running Pi task into a fresh, focused session&#xA;without losing the way back.&lt;/p&gt;&#xA;&lt;p&gt;I wanted &lt;a href=&#34;https://ampcode.com/&#34;&gt;Amp&lt;/a&gt;&amp;rsquo;s handoff workflow in Pi, so I vendored the&#xA;&lt;code&gt;handoff&lt;/code&gt; and &lt;code&gt;session_query&lt;/code&gt; pieces of&#xA;&lt;a href=&#34;https://github.com/pasky/pi-amplike&#34;&gt;&lt;code&gt;pasky/pi-amplike&lt;/code&gt;&lt;/a&gt; as a local package:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git show --stat --oneline 77af167&#xA;77af167 Add Amp-like handoff package&#xA; pi/.pi/README.md                                   |   2 &amp;#43;&#xA; pi/.pi/agent/local/handoff-amp/LICENSE             |  21 &amp;#43;&#xA; pi/.pi/agent/local/handoff-amp/README.md           |  28 &amp;#43;&amp;#43;&#xA; .../agent/local/handoff-amp/extensions/handoff.ts  | 553 &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; .../local/handoff-amp/extensions/lib/mode-utils.ts | 127 &amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&#xA; .../local/handoff-amp/extensions/session-query.ts  | 198 &amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&amp;#43;&#xA; pi/.pi/agent/local/handoff-amp/package.json        |  17 &amp;#43;&#xA; .../handoff-amp/skills/session-query/SKILL.md      |  35 &amp;#43;&amp;#43;&#xA; pi/.pi/agent/settings.json                         |   1 &amp;#43;&#xA; 9 files changed, 982 insertions(&amp;#43;)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;From the editor, &lt;code&gt;/handoff&lt;/code&gt; accepts a goal and optional mode or model:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;/handoff execute phase one of the plan&#xA;/handoff -mode rush execute phase one of the plan&#xA;/handoff -model anthropic/claude-haiku-4-5 check other places that need this fix&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The extension serializes the current branch, asks the model for a focused&#xA;context summary, and includes the source session path in the new prompt:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typescript&#34;&gt;// ~/.pi/agent/local/handoff-amp/extensions/handoff.ts&#xA;const currentSessionFile = ctx.sessionManager.getSessionFile();&#xA;&#xA;if (currentSessionFile) {&#xA;  finalPrompt = `${goal}\n\n/skill:session-query\n\n**Parent session:** \`${currentSessionFile}\`\n\n${result}`;&#xA;}&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The important part: handoff does not open a second Pi process. The command&#xA;replaces the active session inside the current process:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typescript&#34;&gt;const newSessionResult = await cmdCtx.newSession({&#xA;  parentSession: currentSessionFile,&#xA;});&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;When the agent invokes the &lt;code&gt;handoff&lt;/code&gt; tool instead, Pi waits for the current turn&#xA;to finish, then changes the session file:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-typescript&#34;&gt;pi.on(&amp;#34;agent_end&amp;#34;, (_event, ctx) =&amp;gt; {&#xA;  if (!pendingHandoff) return;&#xA;&#xA;  const { prompt, parentSession } = pendingHandoff;&#xA;  (ctx.sessionManager as any).newSession({ parentSession });&#xA;});&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Handoff initiated. The session will switch after the current turn completes.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Same TUI, new active session. The old session remains saved as the parent. To&#xA;return to it, open the session picker:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;/resume&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Or start Pi with that picker from the shell:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% pi -r&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;/tree&lt;/code&gt; is not the way back: it navigates branches inside the current session&#xA;file. Handoff creates another session file, so &lt;code&gt;/resume&lt;/code&gt; is the matching&#xA;operation.&lt;/p&gt;&#xA;&lt;h2 id=&#34;conclusion&#34;&gt;&#xA;  Conclusion&#xA;  &lt;a class=&#34;heading-anchor&#34; href=&#34;https://perrotta.dev/2026/08/pi-handing-off-to-a-focused-session/#conclusion&#34; aria-label=&#34;Link to this section&#34;&gt;#&lt;/a&gt;&#xA;&lt;/h2&gt;&#xA;&lt;p&gt;&lt;strong&gt;Human voice&lt;/strong&gt;: Guess what, after doing all of the above I came to the&#xA;conclusion that this is a cumbersome workflow. No wonder the Amp folks&#xA;&lt;a href=&#34;https://www.nicolaygerold.com/posts/kirby-is-eating-context-engineering&#34;&gt;seem&lt;/a&gt;&#xA;to have reached the same conclusion:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;Handoff is another one of my features that I expect to die soon.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;I am ditching it.&lt;/p&gt;&#xA;&lt;p&gt;A better workflow is to backtrack the session tree with &lt;code&gt;/tree&lt;/code&gt; or &lt;code&gt;Esc Esc&lt;/code&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: pi: handing off to a focused session&#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/coding/&#34;&gt;#coding&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>markdown: nested code fences
      </title>
      <link>https://perrotta.dev/2026/08/markdown-nested-code-fences/</link>
      <pubDate>Mon, 17 Aug 2026 01:27:47 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>coding</category>
      <category>dev</category>
      <category>meta</category>
      <guid>https://perrotta.dev/2026/08/markdown-nested-code-fences/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: a code block that quotes a markdown file swallows the&#xA;first closing fence it meets, which is the &lt;em&gt;inner&lt;/em&gt; one.&lt;/p&gt;&#xA;&lt;p&gt;The &lt;a href=&#34;https://perrotta.dev/2026/01/adrs-and-llms/&#34;&gt;ADRs and LLMs&lt;/a&gt; post quotes a&#xA;&lt;code&gt;README.md&lt;/code&gt; that itself contains a fenced template. Written naively:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-markdown&#34;&gt;```markdown&#xA;# README&#xA;&#xA;Use this template:&#xA;&#xA;```markdown&#xA;# ADR-NNNN: Title&#xA;```&#xA;&#xA;That was the template.&#xA;```&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The &lt;a href=&#34;https://spec.commonmark.org/0.31.2/#fenced-code-blocks&#34;&gt;CommonMark spec&lt;/a&gt;&#xA;says who wins:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;The content of the code block consists of all subsequent lines, until a&#xA;closing code fence of the same type as the code block began with (backticks&#xA;or tildes), and with at least as many backticks or tildes as the opening code&#xA;fence.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;Three backticks close three backticks, so the template&amp;rsquo;s closing fence&#xA;terminates the &lt;em&gt;outer&lt;/em&gt; block. Everything after it leaks into the page:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-html&#34;&gt;&amp;lt;div class=&amp;#34;codeblock&amp;#34; data-lang=&amp;#34;markdown&amp;#34;&amp;gt;&#xA;  &amp;lt;!-- header --&amp;gt;&#xA;&amp;lt;p&amp;gt;That was the template.&amp;lt;/p&amp;gt;&#xA;&amp;lt;div class=&amp;#34;codeblock&amp;#34;&amp;gt;&#xA;   &amp;lt;div class=&amp;#34;highlight&amp;#34;&amp;gt;&amp;lt;pre tabindex=&amp;#34;0&amp;#34; class=&amp;#34;chroma&amp;#34;&amp;gt;&amp;lt;code class=&amp;#34;language-plaintext&amp;#34; data-lang=&amp;#34;plaintext&amp;#34;&amp;gt;&amp;lt;/code&amp;gt;&amp;lt;/pre&amp;gt;&amp;lt;/div&amp;gt;&#xA;&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Two code blocks and a stray paragraph, where one code block was meant.&lt;/p&gt;&#xA;&lt;p&gt;The fix is a longer outer fence — four backticks, or more:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-markdown&#34;&gt;````markdown&#xA;# README&#xA;&#xA;Use this template:&#xA;&#xA;```markdown&#xA;# ADR-NNNN: Title&#xA;```&#xA;&#xA;That was the template.&#xA;````&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Two details make this worth writing down.&lt;/p&gt;&#xA;&lt;p&gt;The damage is quiet. On the real file the outer fence was &lt;code&gt;shell&lt;/code&gt;, so Hugo&#xA;rendered a plausible block that had merely lost its last line. Comparing the&#xA;rendered code block before and after the fix:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;-lang: shell&#xA;&amp;#43;lang: markdown&#xA; …What becomes easier or more difficult to do because of this change?&#xA;&amp;#43;```&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;And &lt;code&gt;markdownlint&lt;/code&gt; was happy the whole time:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% prek run markdownlint --files content/posts/2026-01-11-adrs-and-llms.md&#xA;markdownlint-cli2........................................................Passed&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Rule of thumb: count the backticks of the innermost block, then add one for&#xA;every level above it. ∎&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: markdown: nested code fences&#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/coding/&#34;&gt;#coding&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>homebrew: cask drift after an in-app update
      </title>
      <link>https://perrotta.dev/2026/08/homebrew-cask-drift-after-an-in-app-update/</link>
      <pubDate>Sun, 16 Aug 2026 14:11:17 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>coding</category>
      <category>dev</category>
      <category>macos</category>
      <guid>https://perrotta.dev/2026/08/homebrew-cask-drift-after-an-in-app-update/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/08/homebrew-replace-deprecated-and-orphaned-packages/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: every &lt;code&gt;brew upgrade&lt;/code&gt; died on a cask whose app had&#xA;upgraded itself years ago.&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% brew upgrade&#xA;==&amp;gt; Upgrading 1 outdated package:&#xA;1password 7.9.2 -&amp;gt; 8.12.33&#xA;==&amp;gt; Upgrading 1password&#xA;  7.9.2 -&amp;gt; 8.12.33&#xA;Error: 1password: It seems the App source &amp;#39;/Applications/1Password 7.app&amp;#39; is not there.&#xA;==&amp;gt; Purging files for version 8.12.33 of Cask 1password&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That path stopped existing when 1Password 8 renamed its bundle from&#xA;&lt;code&gt;1Password 7.app&lt;/code&gt; to &lt;code&gt;1Password.app&lt;/code&gt;. The app had updated itself in place,&#xA;behind Homebrew&amp;rsquo;s back:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% defaults read /Applications/1Password.app/Contents/Info.plist CFBundleShortVersionString&#xA;8.12.30&#xA;% brew list --cask --versions 1password&#xA;1password 7.9.2&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Two disagreeing sources of truth. &lt;code&gt;--adopt&lt;/code&gt; looked like the obvious cure, and&#xA;wasn&amp;rsquo;t — the stale receipt still routes into the upgrade code path, which still&#xA;looks for the v7 bundle:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% brew install --cask --adopt 1password&#xA;==&amp;gt; Upgrading 1password&#xA;  7.9.2 -&amp;gt; 8.12.33&#xA;Error: 1password: It seems the App source &amp;#39;/Applications/1Password 7.app&amp;#39; is not there.&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The receipt is the whole problem, so look at what it actually holds:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% find /opt/homebrew/Caskroom/1password -maxdepth 2&#xA;/opt/homebrew/Caskroom/1password&#xA;/opt/homebrew/Caskroom/1password/.metadata&#xA;/opt/homebrew/Caskroom/1password/.metadata/config.json&#xA;/opt/homebrew/Caskroom/1password/.metadata/7.9.2&#xA;/opt/homebrew/Caskroom/1password/7.9.2&#xA;% ls -A /opt/homebrew/Caskroom/1password/7.9.2/&#xA;% du -sh /opt/homebrew/Caskroom/1password&#xA;8.0K&#x9;/opt/homebrew/Caskroom/1password&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Eight kilobytes of bookkeeping and an empty version directory. Nothing in there&#xA;is the application. Delete it, then reinstall over the app that is really on&#xA;disk:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% rm -rf /opt/homebrew/Caskroom/1password&#xA;% brew install --cask --force 1password&#xA;==&amp;gt; Fetching downloads for: 1password&#xA;✔︎ Cask 1password (8.12.33)&#xA;Warning: It seems there is already an App at &amp;#39;/Applications/1Password.app&amp;#39;; overwriting.&#xA;==&amp;gt; Installing Cask 1password&#xA;==&amp;gt; Removing App &amp;#39;/Applications/1Password.app&amp;#39;&#xA;==&amp;gt; Moving App &amp;#39;1Password.app&amp;#39; to &amp;#39;/Applications/1Password.app&amp;#39;&#xA;🍺  1password was successfully installed!&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Both sides agree again:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% defaults read /Applications/1Password.app/Contents/Info.plist CFBundleShortVersionString&#xA;8.12.33&#xA;% brew list --cask --versions 1password&#xA;1password 8.12.33&#xA;% brew outdated --cask | grep 1password&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Quitting the app first is mandatory, and this one ignored two polite&#xA;&lt;code&gt;osascript&lt;/code&gt; requests before a plain &lt;code&gt;kill&lt;/code&gt; took:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% osascript -e &amp;#39;quit app &amp;#34;1Password&amp;#34;&amp;#39;&#xA;% pgrep -x 1Password&#xA;740&#xA;% kill 740&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The general shape: a cask whose app ships its own updater will drift, and&#xA;Homebrew only notices when the vendor also renames the bundle. &lt;code&gt;--adopt&lt;/code&gt; is for&#xA;an app Homebrew never tracked; for an app it tracked &lt;em&gt;wrongly&lt;/em&gt;, the receipt has&#xA;to go first. ∎&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: homebrew: cask drift after an in-app update&#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/coding/&#34;&gt;#coding&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>new tag: bloggify
      </title>
      <link>https://perrotta.dev/2026/08/new-tag-bloggify/</link>
      <pubDate>Sun, 16 Aug 2026 13:50:00 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>bloggify</category>
      <category>dev</category>
      <category>meta</category>
      <guid>https://perrotta.dev/2026/08/new-tag-bloggify/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/06/bloggify/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: some readers may not want AI-assisted posts in their feed&#xA;reader, even with my edits. I support making it easier for people to opt out&#xA;from such posts. They should be able to filter them out without unsubscribing&#xA;from the whole blog.&lt;/p&gt;&#xA;&lt;p&gt;Every post the &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;&#xA;skill drafts already carries a footer disclosing it. What was missing was a&#xA;machine-readable signal, not just a line at the bottom a feed reader can&amp;rsquo;t act&#xA;on. So I grepped for the marker and tagged every post that has it:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% grep -rl &amp;#34;Drafted with \[\`/bloggify\`\]&amp;#34; content/posts/ | wc -l&#xA;27&lt;/code&gt;&lt;/pre&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git show 79018e8cf1 -- content/posts/2026-06-09-bloggify.md&#xA;diff --git content/posts/2026-06-09-bloggify.md content/posts/2026-06-09-bloggify.md&#xA;index b0a5a8ee86..9d15d656fd 100644&#xA;--- content/posts/2026-06-09-bloggify.md&#xA;&amp;#43;&amp;#43;&amp;#43; content/posts/2026-06-09-bloggify.md&#xA;@@ -3,6 &amp;#43;3,7 @@ title: &amp;#34;bloggify&amp;#34;&#xA; date: 2026-06-09T16:50:11&amp;#43;02:00&#xA; tags:&#xA;   - ai&#xA;&amp;#43;  - bloggify&#xA;   - claude&#xA;   - dev&#xA;   - meta&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Every post&amp;rsquo;s &lt;code&gt;tags:&lt;/code&gt; list ends up in the RSS &lt;code&gt;&amp;lt;item&amp;gt;&lt;/code&gt; as a &lt;code&gt;&amp;lt;category&amp;gt;&lt;/code&gt;&#xA;element, one per tag (see &lt;code&gt;layouts/_default/rss.xml&lt;/code&gt;). So &lt;code&gt;bloggify&lt;/code&gt; is now a&#xA;regular, filterable category in the feed, same as any other tag.&lt;/p&gt;&#xA;&lt;p&gt;For those of you who use &lt;a href=&#34;https://miniflux.app/&#34;&gt;Miniflux&lt;/a&gt;, filtering it out is the same trick from&#xA;&lt;a href=&#34;https://perrotta.dev/2026/02/miniflux-block-entry-matching-url/&#34;&gt;blocking an entry matching a URL&lt;/a&gt; and the &lt;a href=&#34;https://perrotta.dev/2024/12/miniflux-global-blocklist/&#34;&gt;global&#xA;blocklist&lt;/a&gt;, except on the&#xA;&lt;code&gt;EntryTag&lt;/code&gt; field instead of &lt;code&gt;EntryURL&lt;/code&gt; or &lt;code&gt;EntryTitle&lt;/code&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-ini&#34;&gt;EntryTag=(?i)^bloggify$&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Added as a global block rule in miniflux, and every future &lt;code&gt;/bloggify&lt;/code&gt;-drafted&#xA;post never reaches the inbox. &lt;code&gt;/tags/bloggify/&lt;/code&gt; on the blog itself works too,&#xA;for anyone who&amp;rsquo;d rather subscribe to the opposite: only the AI-assisted posts. ∎&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: new tag: bloggify&#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/meta/&#34;&gt;#meta&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pi: prompt before dangerous commands
      </title>
      <link>https://perrotta.dev/2026/08/pi-prompt-before-dangerous-commands/</link>
      <pubDate>Sun, 16 Aug 2026 13:28:44 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>bloggify</category>
      <category>coding</category>
      <category>dev</category>
      <category>pi</category>
      <category>security</category>
      <guid>https://perrotta.dev/2026/08/pi-prompt-before-dangerous-commands/</guid>
      <description>&lt;p&gt;♠ &lt;a href=&#34;https://perrotta.dev/2026/07/pi-block-dangerous-commands-syntax-aware/&#34;&gt;Previously&lt;/a&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Problem statement&lt;/strong&gt;: my Pi extension blocked dangerous shell commands outright,&#xA;even when I had already preserved the state they could destroy.&lt;/p&gt;&#xA;&lt;p&gt;The guard parses every &lt;code&gt;bash&lt;/code&gt; tool call with tree-sitter, catching commands such&#xA;as &lt;code&gt;rm -rf&lt;/code&gt;, &lt;code&gt;terraform destroy&lt;/code&gt;, and destructive Git operations. Today it did&#xA;exactly what I had asked:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% git -C &amp;#34;$(brew --repo thiagowfx/pancake)&amp;#34; reset --hard origin/master&#xA;git reset --hard is blocked - discards changes irreversibly&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Blocking remains the right default, but an interactive session can ask me. Pi&#xA;extensions can intercept the&#xA;&lt;a href=&#34;https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/docs/extensions.md#tool_call&#34;&gt;&lt;code&gt;tool_call&lt;/code&gt; event&lt;/a&gt;&#xA;and open a confirmation dialog through &lt;code&gt;ctx.ui&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;p&gt;The change in&#xA;&lt;a href=&#34;https://github.com/thiagowfx/.dotfiles/commit/b5cb42f8deafae176ecddc12fccbd7c04ca264dd&#34;&gt;&lt;code&gt;dangerous-command-guard/guard.ts&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;-  pi.on(&amp;#34;tool_call&amp;#34;, async (event) =&amp;gt; {&#xA;&amp;#43;  pi.on(&amp;#34;tool_call&amp;#34;, async (event, ctx) =&amp;gt; {&#xA;     if (event.toolName !== &amp;#34;bash&amp;#34;) return;&#xA;&#xA;     const command = event.input.command;&#xA;     if (typeof command !== &amp;#34;string&amp;#34;) return;&#xA;&#xA;     const blocked = await findBlockedCommand(command);&#xA;-    if (blocked) return { block: true, reason: blocked.reason };&#xA;&amp;#43;    if (!blocked) return;&#xA;&amp;#43;    if (!ctx.hasUI) return { block: true, reason: blocked.reason };&#xA;&amp;#43;&#xA;&amp;#43;    const allowed = await ctx.ui.confirm(&#xA;&amp;#43;      &amp;#34;Allow dangerous command?&amp;#34;,&#xA;&amp;#43;      `${blocked.command}\n\n${blocked.reason}`,&#xA;&amp;#43;    );&#xA;&amp;#43;    if (!allowed) return { block: true, reason: &amp;#34;Blocked by user&amp;#34; };&#xA;   });&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Interactive Pi now pauses for approval. Print and JSON modes have no UI, so they&#xA;still fail closed. Safe commands never prompt.&lt;/p&gt;&#xA;&lt;p&gt;The tests exercise all three decisions:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;✔ extension prompts before dangerous bash tool calls (0.66225ms)&#xA;ℹ tests 48&#xA;ℹ pass 48&#xA;ℹ fail 0&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Same guardrails, with an escape hatch operated by a human rather than a model. ∎&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 before dangerous commands&#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/coding/&#34;&gt;#coding&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;a href=&#34;https://perrotta.dev/tags/security/&#34;&gt;#security&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>pi: am I sandboxed?
      </title>
      <link>https://perrotta.dev/2026/08/pi-am-i-sandboxed/</link>
      <pubDate>Sat, 15 Aug 2026 00:56:29 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>ai</category>
      <category>bloggify</category>
      <category>coding</category>
      <category>dev</category>
      <category>pi</category>
      <category>security</category>
      <guid>https://perrotta.dev/2026/08/pi-am-i-sandboxed/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: asking a coding agent whether it runs in a sandbox&#xA;returns a confident answer derived from its system prompt, not from the machine&#xA;it is actually running on.&lt;/p&gt;&#xA;&lt;p&gt;I asked &lt;a href=&#34;https://github.com/earendil-works/pi-mono&#34;&gt;Pi&lt;/a&gt; exactly that. The reply&#xA;was assured:&lt;/p&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;No — pi itself doesn&amp;rsquo;t sandbox tool execution. When I run &lt;code&gt;bash&lt;/code&gt;, &lt;code&gt;edit&lt;/code&gt;, or&#xA;&lt;code&gt;write&lt;/code&gt;, they execute directly on your machine with your user&amp;rsquo;s permissions,&#xA;in the current working directory (&lt;code&gt;/Users/thiago.perrotta/.pi&lt;/code&gt;). [&amp;hellip;] But&#xA;there&amp;rsquo;s no OS-level sandbox (no container, no seccomp, no filesystem jail).&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;It&amp;rsquo;s plausible, well-formatted, and&amp;hellip;&lt;strong&gt;wrong&lt;/strong&gt;!. One typo-ridden command was&#xA;enough to break the illusion:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ls ~/worksppace&#xA;ls: cannot access &amp;#39;/home/hostuser/worksppace&amp;#39;: No such file or directory&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;~&lt;/code&gt; is not &lt;code&gt;/Users/thiago.perrotta&lt;/code&gt;. The follow-up probe:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% pwd; uname -a; whoami; cat /etc/os-release&#xA;/Users/thiago.perrotta/.pi&#xA;Linux lima-rancher-desktop 6.18.37-0-virt #1-Alpine SMP PREEMPT_DYNAMIC 2026-06-29 10:52:25 aarch64 GNU/Linux&#xA;hostuser&#xA;PRETTY_NAME=&amp;#34;Debian GNU/Linux 12 (bookworm)&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A macOS-shaped &lt;code&gt;pwd&lt;/code&gt; inside an aarch64 Debian container, on an Alpine&#xA;&lt;a href=&#34;https://lima-vm.io/&#34;&gt;Lima&lt;/a&gt; VM, under Rancher Desktop. &lt;em&gt;Containerception&lt;/em&gt;.&lt;/p&gt;&#xA;&lt;p&gt;And the giveaway at the filesystem root:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% ls -la / | head -5&#xA;total 68&#xA;drwxr-xr-x   1 root root 4096 Aug 15 00:54 .&#xA;drwxr-xr-x   1 root root 4096 Aug 15 00:54 ..&#xA;-rwxr-xr-x   1 root root    0 Aug 15 00:54 .dockerenv&#xA;drwxr-xr-x   3 root root 4096 Aug 15 00:54 Users&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;/.dockerenv&lt;/code&gt;, plus a &lt;code&gt;/Users&lt;/code&gt; bind mount recreating just enough of the host&#xA;path for the system prompt&amp;rsquo;s &lt;code&gt;cwd&lt;/code&gt; to resolve.&lt;/p&gt;&#xA;&lt;p&gt;The model was not lying; it had no way to know. Its self-description comes from&#xA;tokens someone else wrote, while the ground truth sits one &lt;code&gt;uname&lt;/code&gt; away. The&#xA;useful lesson is that agent introspection is a documentation lookup, not a&#xA;measurement — for anything security-relevant, make it run the command.&lt;/p&gt;&#xA;&lt;p&gt;The sandbox was &lt;a href=&#34;https://perrotta.dev/2026/02/cco-claude-condom-sandbox/&#34;&gt;cco&lt;/a&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: pi: am I sandboxed?&#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/coding/&#34;&gt;#coding&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;a href=&#34;https://perrotta.dev/tags/security/&#34;&gt;#security&lt;/a&gt;&lt;/p&gt;</description>
    </item>
    <item>
      <title>homebrew: replace deprecated and orphaned packages
      </title>
      <link>https://perrotta.dev/2026/08/homebrew-replace-deprecated-and-orphaned-packages/</link>
      <pubDate>Mon, 10 Aug 2026 15:10:42 +0200</pubDate><author>serendipity@perrotta.dev (Thiago Perrotta)</author>
      <category>bloggify</category>
      <category>coding</category>
      <category>dev</category>
      <category>macos</category>
      <guid>https://perrotta.dev/2026/08/homebrew-replace-deprecated-and-orphaned-packages/</guid>
      <description>&lt;p&gt;♠ &lt;strong&gt;Problem statement&lt;/strong&gt;: &lt;code&gt;brew doctor&lt;/code&gt; found one deprecated cask and one installed&#xA;keg whose formula had disappeared.&lt;/p&gt;&#xA;&lt;h2 id=&#34;tflint&#34;&gt;&#xA;  tflint&#xA;  &lt;a class=&#34;heading-anchor&#34; href=&#34;https://perrotta.dev/2026/08/homebrew-replace-deprecated-and-orphaned-packages/#tflint&#34; aria-label=&#34;Link to this section&#34;&gt;#&lt;/a&gt;&#xA;&lt;/h2&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-plaintext&#34;&gt;Warning: Some installed casks are deprecated or disabled.&#xA;You should find replacements for the following casks:&#xA;flameshot&#xA;&#xA;Warning: Some installed kegs have no formulae!&#xA;You should find replacements for the following formulae:&#xA;  tflint&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Homebrew/core &lt;a href=&#34;https://github.com/Homebrew/homebrew-core/commit/6cefefcbba24e4a1093dfd2e189f2cb01170e45f&#34;&gt;removed TFLint&lt;/a&gt;&#xA;after its executable became subject to both MPL 2.0 and BUSL 1.1. Upstream now&#xA;maintains &lt;a href=&#34;https://github.com/terraform-linters/homebrew-tap&#34;&gt;&lt;code&gt;terraform-linters/tap&lt;/code&gt;&lt;/a&gt;&#xA;as a cask, so migrate the old formula instead of merely reinstalling it:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% brew uninstall tflint&#xA;Uninstalling /opt/homebrew/Cellar/tflint/0.61.0... (8 files, 51.8MB)&#xA;&#xA;% brew install terraform-linters/tap/tflint&#xA;==&amp;gt; Tapping terraform-linters/tap&#xA;Tapped 1 cask (15 files, 14.7KB).&#xA;==&amp;gt; Installing Cask tflint&#xA;==&amp;gt; Linking Binary &amp;#39;tflint&amp;#39; to &amp;#39;/opt/homebrew/bin/tflint&amp;#39;&#xA;🍺  tflint was successfully installed!&#xA;&#xA;% tflint --version&#xA;TFLint version 0.64.0&#xA;&amp;#43; ruleset.terraform (0.15.0-bundled)&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;h2 id=&#34;flameshot--shottr&#34;&gt;&#xA;  Flameshot → Shottr&#xA;  &lt;a class=&#34;heading-anchor&#34; href=&#34;https://perrotta.dev/2026/08/homebrew-replace-deprecated-and-orphaned-packages/#flameshot--shottr&#34; aria-label=&#34;Link to this section&#34;&gt;#&lt;/a&gt;&#xA;&lt;/h2&gt;&#xA;&lt;p&gt;The &lt;a href=&#34;https://formulae.brew.sh/cask/flameshot&#34;&gt;Flameshot cask&lt;/a&gt; was deprecated&#xA;because it does not pass macOS Gatekeeper checks. I replaced it with&#xA;&lt;a href=&#34;https://shottr.cc/&#34;&gt;Shottr&lt;/a&gt;:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% brew uninstall --cask flameshot&#xA;==&amp;gt; Uninstalling Cask flameshot&#xA;==&amp;gt; Removing App &amp;#39;/Applications/flameshot.app&amp;#39;&#xA;==&amp;gt; Purging files for version 14.0.0,14.0 of Cask flameshot&#xA;&#xA;% brew install --cask shottr&#xA;==&amp;gt; Installing Cask shottr&#xA;==&amp;gt; Moving App &amp;#39;Shottr.app&amp;#39; to &amp;#39;/Applications/Shottr.app&amp;#39;&#xA;🍺  shottr was successfully installed!&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The corresponding &lt;code&gt;Brewfile&lt;/code&gt; &lt;a href=&#34;https://github.com/thiagowfx/.dotfiles/commit/79cd3dd4eb448d4a3bc5f66a124fd60d3f131d58&#34;&gt;change&lt;/a&gt; keeps the migration reproducible:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-diff&#34;&gt;&amp;#43;tap &amp;#34;terraform-linters/tap&amp;#34;&#xA;&#xA;-brew &amp;#34;tflint&amp;#34;&#xA;&#xA;-cask &amp;#34;flameshot&amp;#34;&#xA;&amp;#43;cask &amp;#34;shottr&amp;#34;&#xA;&amp;#43;cask &amp;#34;tflint&amp;#34;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Both offending entries are now gone:&lt;/p&gt;&#xA;&#xA;&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;% brew doctor 2&amp;gt;&amp;amp;1 | grep -E &amp;#39;flameshot|tflint|deprecated|no formulae&amp;#39;&#xA;&#xA;% brew list --cask | grep -E &amp;#39;^(flameshot|shottr|tflint)$&amp;#39;&#xA;shottr&#xA;tflint&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: homebrew: replace deprecated and orphaned packages&#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/coding/&#34;&gt;#coding&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>
  </channel>
</rss>
