release: prep v2.7.0 — consolidated notes + in-app Sparkle release notes

Rolls up everything since v2.6.5 (36 commits across remote-perf,
project wizard, dashboard widgets, OAuth resilience, ScarfMon
instrumentation, and the v2.7 skeleton-then-hydrate redesign) into
a single 2.7.0 release.

* releases/v2.7.0/RELEASE_NOTES.md — full consolidated notes,
  reorganized around the throughline (slow-remote performance) with
  five thematic sections: skeleton-then-hydrate loaders, SSH
  cancellation, project wizard + Keychain cron secrets, dashboard
  widgets, OAuth resilience, and ScarfMon. Replaces the previously-
  drafted dashboard-only v2.7.0 stub and the separate v2.8 wizard
  stub (both unreleased).
* releases/v2.8/ — deleted; folded into v2.7.
* README.md — "What's New in 2.6" → "What's New in 2.7" with the
  five-section summary linking out to the full notes.

* tools/render-release-notes.py — stdlib-only Markdown → HTML
  renderer covering the subset of GitHub-flavored markdown that
  release notes use (## / ### headings, paragraphs, ul lists,
  fenced code, inline code/bold/italic/links, hr). Output includes
  a small <style> block tuned for Sparkle's update alert WebKit
  view (light + dark variants via prefers-color-scheme).
* scripts/release.sh — render the active RELEASE_NOTES.md and
  inject the result as <description><![CDATA[...]]></description>
  on the appcast item. Sparkle's standard updater renders this in
  the in-app update sheet so users see release-specific "what's
  new" alongside the version number, not just the bare version.
  Falls back to a "see GitHub release page" placeholder when the
  notes file is missing.

User runs ./scripts/release.sh 2.7.0 to ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-05 20:31:27 +02:00
parent 5e23b59697
commit cd5bb32a21
5 changed files with 420 additions and 149 deletions
+38 -25
View File
@@ -19,39 +19,52 @@
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p>
## What's New in 2.6
## What's New in 2.7
### Hermes v2026.4.30 (v0.12.0) catch-up
The biggest release since 2.6 — six weeks of work focused on **remote-context performance**, a **new project authoring flow**, **dashboard widgets**, **OAuth resilience**, and a top-to-bottom **performance instrumentation harness** that drove the bulk of the rest. 36 commits, no schema bump, no Hermes capability bump.
The largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Every new surface is **capability-gated** through `HermesCapabilities` (parses `hermes --version` once per server) — on a v0.11 host, Scarf 2.6 looks identical to Scarf 2.5.2 and the new affordances are hidden.
### Remote chats and Activity in seconds, not 30s timeouts
- **Autonomous Curator (Mac sidebar + iOS panel).** `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Status panel, **Run Now / Pause / Resume** actions, three leaderboards (least-recently-active / most-active / least-active) with activity / use / view / patch counters, inline pin toggles, restore-archived sheet. Last-run REPORT.md renders inline. New "Curator" sidebar item under Interact (between Memory and Skills); ScarfGo gets a Curator nav row under System.
- **Multimodal image input in chat.** Drag/drop, paste, or NSOpenPanel multi-pick on Mac; PhotosPicker on iOS (up to 5 images per message). `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85, **detached only** so encoding never blocks MainActor. Hermes routes the prompt to a vision-capable model automatically. Image-only sends are valid — vision models accept "describe this" with no caption.
- **5 new inference providers** in the model picker — GMI Cloud, Azure AI Foundry, LM Studio (now first-class), MiniMax (OAuth), Tencent TokenHub. Provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly.
- **Microsoft Teams + Yuanbao** as the 18th and 19th gateway platforms in the Platforms tab.
- **Read-only Kanban view (Mac).** Paginated table over `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and 5s polling while foregrounded. Create / claim / dispatch UI is deferred until upstream stabilizes the multi-profile collab layer (which was reverted in v0.12).
- **Skills v0.12 surface.** Direct-URL install (`hermes skills install <https-url>`) via a new "Install from URL…" toolbar button on Mac; reload via `hermes skills audit`; `skills.disabled` rendered as strikethrough + an "OFF" pill on Mac and iOS rows; Curator pin badge from `~/.hermes/skills/.curator_state` surfaced as a pin glyph.
- **Cron — `--workdir` field (Mac).** Inject `AGENTS.md` / `CLAUDE.md` / `.cursorrules` from a working directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor adds the field; both create and edit paths forward the flag.
- **Settings deltas.** New **Caching & Redaction** section under Advanced — prompt cache TTL picker (5m / 1h), redact-secrets-in-patches toggle (now off by default on v0.12; flip back on here), runtime metadata footer toggle. TTS provider list gains **piper** (native local TTS); terminal backend list gains **vercel** (Vercel Sandbox).
- **`auxiliary.curator` aux task.** Curator's review fork can run on a separate model from the main agent. `auxiliary.flush_memories` was removed in v0.12 — Scarf preserves the row on v0.11 hosts (inverse gate) and hides it on v0.12.
- **ScarfGo catch-up.** Read-only Webhooks / Plugins / Profiles tabs parity-match the Mac surfaces (no mutating CLI verbs on the phone). Yellow Hermes-version banner nudges pre-v0.12 hosts to upgrade; renders only when the connected target is below v0.12.
Resuming a chat or opening Activity on a slow remote (a 420ms-RTT droplet, an underprovisioned VPS, a tunnel through 4G) used to fetch the full message column set in one shot, which routinely tripped the 30s SSH timeout on chats with multi-page tool result blobs. v2.7 introduces a **skeleton-then-hydrate pattern** that bounds the wire payload by what the user actually needs to see RIGHT NOW, then fills in the heavy stuff in the background.
### Chat fixes (post-merge round)
- **Chat skeleton** — user + assistant rows only (skips `role='tool'`), `tool_calls` / `reasoning` hard-NULLed at SQL level. Wire payload bounded by conversational text. The chat appears in seconds. Background hydration pages tool calls in 5-id batches; tool-result CONTENT is opt-in (Settings → Display → "Load tool results in past chats", default off) with per-card lazy-fetch in the inspector pane.
- **Activity skeleton** — metadata-only fetch (~3 KB for 50 rows). Placeholder rows render immediately; real per-call entries swap in as paged hydration completes.
- **Single-id whale recovery** — when a 5-id batch trips the 30s timeout (one row carries an oversized `tool_calls` blob), an L1 single-id retry isolates the offender so the rest of the batch still hydrates.
A focused pass over GitHub issue triage:
### SSH cancellation that actually cancels
- **Typing lag in the chat composer ([#67](https://github.com/awizemann/scarf/issues/67))** — `RichChatInputBar.updateMenuState()` was firing on every keystroke and writing two state vars per `.onChange`, tripping SwiftUI's "action tried to update multiple times per frame" warning. Composer now coalesces writes, short-circuits when the slash menu can't apply, and watches `commands.count` instead of allocating `commands.map(\.id)` per keystroke.
- **Chat font-size slider now actually scales rich chat content ([#68](https://github.com/awizemann/scarf/issues/68))** — `\.dynamicTypeSize` couldn't reach the fixed-point ScarfFont tokens. New `\.chatFontScale` env value plumbed through bubbles, markdown, and code blocks.
- **Placeholder ghosting on first keystroke ([#65](https://github.com/awizemann/scarf/issues/65))** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates. Pinned an opaque background behind the placeholder rect; switched the conditional to `.opacity(...)` for view-tree stability.
- **Draft text leaked between conversations ([#62](https://github.com/awizemann/scarf/issues/62))** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId`.
- **Sent message rendered blank after navigating away ([#63](https://github.com/awizemann/scarf/issues/63))** — `loadSessionHistory` atomically replaced messages from a state.db that hadn't yet flushed the user's row. New per-session pending-user-messages cache survives `reset()` and re-injects entries until the DB catches up.
- **Background completion notifications ([#64](https://github.com/awizemann/scarf/issues/64))** — new `ChatNotificationService` fires a local UNUserNotificationCenter banner when a prompt completes while Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
- **Per-message TTS playback ([#66](https://github.com/awizemann/scarf/issues/66))** — small speaker glyph on each settled assistant bubble. Tap to read aloud through `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice.
- **ACP control-message timeout 30s → 60s ([#61](https://github.com/awizemann/scarf/issues/61))** — gives `initialize` / `session/new` / `session/load` headroom against gateway-induced state.db lock contention.
`Task.detached` doesn't inherit cancellation from the awaiting parent. Pre-fix, navigating away from a chat left the underlying ssh subprocess running for the full 30s, pinning a remote sqlite query and a ControlMaster session — the "third chat hangs" / "dashboard spins after rapid switching" symptom. v2.7 wires `withTaskCancellationHandler` through `SSHScriptRunner.run` and `RemoteSQLiteBackend.query`; cancellation now reaches the `Process` within ~100ms.
See the full [v2.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.6.0).
### New Project from Scratch wizard + Keychain-backed cron secrets
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.5, v2.3, v2.2, v2.0, v1.6, and earlier.
A third project entry point alongside Browse Catalog and Add Existing Project. Scaffolds a Scarf-standard skeleton, registers it, and hands off to a chat session that auto-activates the bundled `scarf-template-author` skill. The skill drives the rest conversationally — widgets, optional config schema, optional cron — and writes the final files itself.
**Cron + Keychain.** Cron prompts that referenced `secret`-typed config fields used to get the literal `keychain://...` URI back, producing 401s. v2.7 mirrors resolved Keychain values into `~/.hermes/.env` under `$SCARF_<UPPER_SLUG>_<UPPER_FIELD>` env vars. Hermes already reloads `.env` per cron tick — credential rotation is automatic.
### Project dashboards — file-reading widgets, sparklines, typed status
Five new widget types and project-wide auto-refresh. **Backwards-compatible** — every existing `dashboard.json` renders byte-identically.
- **`markdown_file`** / **`log_tail`** / **`cron_status`** / **`image`** / **`status_grid`** — file-reading widgets that auto-refresh when the underlying file changes. By convention, place files inside `<project>/.scarf/`.
- **`stat` widget gains inline sparklines** via optional `sparkline: [Number]`. SVG-only render; dozens per dashboard cost nothing.
- **Typed status badges** with lenient decode (`ok`/`up` → success, `down`/`error` → danger). Unknown strings render as plain text rather than crashing.
- **Structured widget error card** replaces the legacy "Unknown: \<type\>" placeholder.
### OAuth resilience + Credential Pools
- **Daily OAuth keepalive cron** prevents Anthropic OAuth refresh tokens from expiring after weeks of inactivity.
- **Remote re-auth** unblocked — OAuth flow drives a remote `hermes auth add` correctly with stdin forwarded.
- **OAuth remove button** + auto-refresh of Credential Pools on `auth.json` change.
- **`resolve_provider_client` errors** (auxiliary task references an unauthenticated provider) classified into a clear hint with a one-click jump to Settings → Aux Models.
- **Model/provider mismatch banner** detects when `model.default` carries a `<provider>/...` prefix that disagrees with `model.provider`, with one-click fix in either direction.
### ScarfMon — performance instrumentation harness
The diagnostic surface that drove the bulk of the v2.7 perf work. Off by default; signpost-only mode (Instruments-friendly) is free; Full mode keeps a 4096-entry in-memory ring buffer you can copy as JSON for paste-into-issue diagnosis. Wiki: [Performance-Monitoring](https://github.com/awizemann/scarf/wiki/Performance-Monitoring).
See the full [v2.7.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.7.0) for the complete list (36 commits, including: in-flight coalescing for `loadRecentSessions`, snapshot pipeline rewrite from `sqlite3 .backup` to direct SSH-streamed queries [#74](https://github.com/awizemann/scarf/issues/74), per-message TTS, window-position persistence, sidebar reorder, and many other fixes).
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.6, v2.5, v2.3, v2.2, v2.0, v1.6, and earlier.
## ScarfGo — the iPhone companion
+125 -31
View File
@@ -1,56 +1,140 @@
## What's in 2.7.0
A focused release on **project dashboards** — the most "live" surface for users running cron-driven workflows. This release does three things at once:
The biggest release since 2.6.0 — a six-week stretch covering **remote-context performance**, a **new project authoring flow**, **dashboard widgets**, **OAuth resilience**, and a top-to-bottom **performance instrumentation harness** that drove the bulk of the rest. 36 commits, no schema bump, no Hermes capability bump.
1. **Auto-refresh now covers the entire project, not just `dashboard.json`.** A widget that points at `<project>/.scarf/reports/uptime.md` refreshes the moment the cron job rewrites it.
2. **Five new widget types** make cron-driven monitoring dashboards much more expressive — render markdown reports from disk, tail log files, surface Hermes cron-job state, embed images, and pack many services into a compact status grid.
3. **`stat` widgets gain inline sparklines.** `list` items get a typed status enum with semantic colors. Unknown widget types render as a structured error card (not a generic "Unknown" placeholder).
The throughline: Scarf got materially faster and more honest on slow remote SSH links, where 30-second sqlite timeouts and silently-empty UI used to be common. The skeleton-then-hydrate pattern, SSH cancellation propagation, and ScarfMon-driven diagnosis are the shape of how that work gets done now.
**Backwards compatible — no schema bump.** Every existing `dashboard.json` renders byte-identically on v2.7. The catalog manifest format is unchanged. v1, v2, v3 bundles install identically as before. Templates that adopt new widget types still validate against the existing manifest schema — only the catalog validator's vocabulary list was extended.
---
### Project-wide auto-refresh
### Remote-context performance — chats and Activity in seconds, not 30s timeouts
[`HermesFileWatcher`](../../scarf/scarf/Core/Services/HermesFileWatcher.swift) used to watch each project's `dashboard.json` file specifically. v2.7 promotes that to a watch on the entire `<project>/.scarf/` directory:
Resuming a chat on a slow remote (a 420ms-RTT droplet, an underprovisioned VPS, a tunnel through 4G) used to fetch the full message column set in one shot, which routinely tripped the 30s SSH timeout on chats with multi-page tool result blobs. The 160-message session was broken; the 30-message session was broken too. Activity didn't load at all.
- **Local** — adds a `DispatchSourceFileSystemObject` per project's `.scarf/` dir alongside the existing per-file watch on `dashboard.json`.
- **Remote (SSH)** — folds project `.scarf/` directories into the existing 3-second mtime poll. Closes the explicit "Phase 4 polish item" deferral that landed in v2.3.
v2.7 introduces a **skeleton-then-hydrate pattern** that bounds the wire payload by what the user actually needs to see RIGHT NOW, then fills in the heavy stuff in the background:
Effect: a `markdown_file` or `log_tail` widget pointing at `<project>/.scarf/reports/foo.md` refreshes automatically when a cron job rewrites the file. **By convention, place files the dashboard reads inside `.scarf/`** so the watch picks them up. Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes.
- **Chat skeleton.** [`fetchSkeletonMessages`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift) selects user + assistant rows only (skips `role='tool'`) with `tool_calls` / `reasoning` / `reasoning_content` hard-NULLed at the SQL level. Wire payload bounded by conversational text alone — typically a few KB. The chat appears in seconds. Background `startToolHydration` pages through `hydrateAssistantToolCalls` in 5-id batches to splice tool calls in. Tool-result CONTENT is **opt-in** via Settings → Display → "Load tool results in past chats" (default off); the inspector pane lazy-fetches per-result content via `fetchToolResult(callId:)` when you open a card.
- **Activity skeleton.** [`fetchRecentToolCallSkeleton`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift) returns metadata-only rows (id + session_id + role + timestamp; everything else NULLed). Activity opens in <1s on remote with placeholder rows; real per-call entries swap in as paged hydration completes. New "Loading tool details…" pill in the page header surfaces hydration progress.
- **Single-id whale recovery.** When a 5-id batch trips the 30s timeout (one row carries an oversized `tool_calls` blob — a long Edit's args, a big diff), an L1 single-id retry isolates the offending row so the rest of the batch still hydrates. Whale row stays bare; assistant message stays readable.
- **Lazy tool result loading in the inspector.** Default-off avoids the bulk fetch. When you focus a tool call card, ChatInspectorPane fires `loadToolResultIfMissing(callId:)` which splices a single result into the message stream without re-fetching anything else.
_Limitation:_ in-place appends to an existing file (`>> file.log`) don't tick the watcher — the cron job should write atomically (write-temp + rename), or `touch dashboard.json` after each run to force a refresh. Per-widget data-source watching (the granular alternative) is deferred to a future release; this project-wide pattern covers the common cron-driven workflow without the extra plumbing.
Effect: a 160-message thinking-model session that used to time out at exactly 30s now opens in under 2 seconds with placeholder cards filling in over the next few. Activity loads in 500-800ms.
### Five new widget types
#### SSH cancellation that actually cancels
All five additive — pre-v2.7 Scarf renders unknown widget types as a clearly-labeled error card now (not a crash). They share two conventions: file paths are resolved relative to the project root with a hard `..`-escape rejection at [`WidgetPathResolver`](../../scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift), and reads happen in `Task.detached` so dashboards never block the main actor.
`Task.detached { … }` doesn't inherit cancellation from the awaiting parent, and `Task<…> { … }` (unstructured) also drops the signal. Without explicit bridging, cancelling a chat-load Task only unwinds Swift state — the underlying ssh subprocess kept running for the full 30s, pinning a remote sqlite query and a ControlMaster session slot. This produced the "third chat hangs" / "dashboard spins after rapid switching" symptom.
- **`markdown_file`** — renders a markdown file from disk through the same `MarkdownContentView` pipeline used by inline `text` widgets. Pair with cron jobs that write longer-form reports.
- **`log_tail`** — last `lines` of a file (default 20, max 200), monospaced, ANSI codes stripped. Killer for "what did my cron job print last run?".
- **`cron_status`** — last run / next run / state for one Hermes cron job by `jobId`, plus a small inline log tail. Read-only — Run / Pause / Resume controls stay on the Cron tab so the dashboard isn't a place where you accidentally fire a job.
- **`image`** — local file (`path` relative to project root, via `transport.readFile`) or remote `url` (via `AsyncImage`). Optional `height` cap. Useful for matplotlib/Plotly PNGs the cron job generates.
- **`status_grid`** — compact NxM grid of colored cells, one per service / item, with hover labels. Reuses the typed status enum so colors stay consistent with `list` widgets.
v2.7 wires `withTaskCancellationHandler` through [`SSHScriptRunner.run`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift) and [`RemoteSQLiteBackend.query`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift) so parent cancellation reaches the `Process` and calls `proc.terminate()` within 100ms. New `ssh.cancelled` ScarfMon event surfaces this.
### `stat` widget gains inline sparklines
#### In-flight coalescing for `loadRecentSessions`
`stat` widgets now accept an optional `sparkline: [Number]` field — a tiny inline trend line under the big number. SVG-only render, no Chart.js dependency, dozens per dashboard cost nothing. Old `stat` widgets without the field render exactly as before.
File-watcher deltas during an active stream used to stack 2-3 parallel sessions-list reload tasks (the 500ms `scheduleSessionsRefresh` debounce only suppresses a pending tick, not one already executing). Subsequent callers now await the in-flight load instead of spawning a parallel SSH subprocess. New `mac.loadRecentSessions.coalesced` event tracks dedup hits.
### Typed status badges (lenient decode)
#### Loading-state UX hardening
`list` items and `status_grid` cells share a typed status enum: `success`, `warning`, `danger`, `info`, `pending`, `done`, `neutral`. Common synonyms map to the canonical case (`ok` / `up` → success, `down` / `error` / `failed` → danger, `active` → info, `complete` → done). Unknown strings render as plain text rather than crashing — the dev's machine alone has dashboards using ad-hoc statuses like `"ok"`, `"up"`, `"info"`, and they all keep working byte-identically. **For new templates, prefer the canonical names** so colors stay predictable across releases.
The Mac chat sidebar greys out and disables row taps the moment a session-switch is initiated (synchronously, before `client.start()` returns), with a floating ProgressView showing the current phase: **"Spawning hermes acp…"** → **"Authenticating…"** → **"Loading session…"** → **"Loading history…"** → **"Ready"**. Pre-fix the sidebar looked engageable while the 5-7 second SSH+ACP boot was still in flight, and the user could queue up a second session-switch behind the first. New `isStartingSession` flag flips on user click for instant feedback.
### Structured widget error card
#### Partial-result + mismatch + pinned-model banners
The legacy "Unknown: \<type\>" placeholder is replaced with a structured error card surfacing the widget's title, the specific reason (unknown type, missing file, parse error, path escapes project root), and a hint. Used by the dispatcher's default branch and by every v2.7 file-reading widget when its underlying data can't be loaded.
- **Partial-result banner.** When the skeleton fetch trips an SSH transport failure (rather than a clean empty result), the chat surfaces "Couldn't load full chat history — the connection to *server* timed out" through the existing `acpError` triplet, plus forces `hasMoreHistory = true` so the "Load earlier" affordance shows up. Replaces the pre-fix silent empty transcript.
- **Model/provider mismatch banner.** [`ModelPreflight.detectMismatch`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift) recognizes when `model.default` carries a `<provider>/...` prefix that disagrees with `model.provider` (e.g. `anthropic/claude-sonnet-4.6` + `provider: nous` after switching OAuth via Credential Pools). Banner offers one-click fix in either direction.
- **Pinned-model failure hint.** ACP error classifier now recognizes `model_not_found` / `404 messages` / `model is not available` and surfaces "This session was created with a model the provider no longer offers — start a new chat to use your current model" so the pinned-model failure mode has a clear recovery path.
- **OAuth-completion provider swap.** After a successful OAuth in Credential Pools, if the just-authed provider differs from `model.provider`, surface "Switch active provider to *name*?" with [Switch] / [Keep current] instead of auto-dismissing.
### Schema mirror — single source of truth
---
The widget vocabulary is now defined once at [`tools/widget-schema.json`](../../tools/widget-schema.json) instead of being maintained in three places by hand. The catalog validator ([`tools/build-catalog.py`](../../tools/build-catalog.py)) reads from it and now enforces per-type required fields (e.g. `cron_status` requires `jobId`, `log_tail` requires `path`). Adding a future widget type means editing one JSON file plus implementing a Swift view + a JS renderer; the validator picks up the addition automatically.
### New Project from Scratch wizard + Keychain-backed cron secrets
### What's deferred
A **third project entry point** alongside Browse Catalog and Add Existing Project: a wizard that scaffolds a Scarf-standard project skeleton (`<project>/.scarf/dashboard.json` + AGENTS.md marker block), registers it, and hands off to a chat session that auto-activates the bundled `scarf-template-author` skill. The skill drives the rest conversationally — widgets, optional config schema, optional cron — and writes the final files itself. Wizard stays minimal because the agent does configuration better than a multi-step form. The skill ships bundled inside `Scarf.app/Contents/Resources/BuiltinSkills.bundle/` and copies into `~/.hermes/skills/` on launch (idempotent + version-gated).
Two items from the design plan stayed deferred:
**Cron + Keychain — `$SCARF_<SLUG>_<FIELD>` env vars.** Cron prompts that referenced `secret`-typed config fields used to get the literal `keychain://...` URI back when reading `config.json`, producing 401s. v2.7 mirrors resolved Keychain values into `~/.hermes/.env` under a marker-bounded block keyed by template slug:
- **Per-widget data sources + per-widget refresh granularity.** The general "widget points at a typed data source (file / cron / json-path / …)" abstraction is the next-largest win in this area but materially expands the model + JS mirror + validator + authoring skill surface. The project-wide watch covers the common cron-driven workflow without it; revisit when a real-world template wants the granular control.
- **Cross-project health digest sidebar rollup.** Counting attention-needed projects across the registry was scoped for this release but ended up not pulling its weight against the rest of the work; the underlying status enum (B.1) makes a future digest cheap to add.
```sh
# scarf-secrets:begin local-news-aggregator
SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN=actual-value
SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL=https://example.com/feed
# scarf-secrets:end local-news-aggregator
```
Hermes already reloads `~/.hermes/.env` per cron tick, so credential rotation is automatic — just edit the value in Configuration → next tick sees it. The mirror runs at every state-change point: install, post-install Configuration save, uninstall, "Remove from List", and on app launch (reconciliation pass over registered projects). Source of truth stays in the Keychain — `config.json` keeps `keychain://` URIs unchanged. Mode 0600 enforced on `~/.hermes/.env`.
Cron prompts now reference these env vars directly:
```json
{
"prompt": "Use the terminal: curl -sS -H \"Authorization: Bearer $SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN\" \"$SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL\" -o {{PROJECT_DIR}}/.scarf/feed.xml"
}
```
**Migration.** First launch of v2.7 walks the project registry and writes the managed block per schemaful project — automatic. Existing cron prompts you wrote against the old (broken) `config.json` pattern still need updating: open the cron job in Scarf's Cron sidebar and edit the prompt, or ask the agent in chat ("Update my Local News cron job's prompt to use the new env var convention") — the bundled `scarf-template-author` skill (now v1.1.0) documents the convention with worked examples.
Also fixes [#75](https://github.com/awizemann/scarf/issues/75) — `_NSDetectedLayoutRecursion` on the Configuration form for projects whose form transitioned between stages with different intrinsic heights.
---
### Project dashboards — file-reading widgets, sparklines, typed status
Five new widget types, project-wide auto-refresh, and a structured error card for unknown widgets. Backwards-compatible — every existing `dashboard.json` renders byte-identically.
- **Project-wide auto-refresh.** [`HermesFileWatcher`](https://github.com/awizemann/scarf/blob/main/scarf/scarf/Core/Services/HermesFileWatcher.swift) used to watch each project's `dashboard.json` specifically. v2.7 promotes that to a watch on the entire `<project>/.scarf/` directory. A `markdown_file` or `log_tail` widget pointing at `<project>/.scarf/reports/foo.md` refreshes the moment a cron job rewrites the file. **By convention, place files the dashboard reads inside `.scarf/`** so the watch picks them up.
- **`markdown_file`** — renders a markdown file from disk through the same `MarkdownContentView` pipeline used by inline `text` widgets.
- **`log_tail`** — last `lines` of a file (default 20, max 200), monospaced, ANSI codes stripped.
- **`cron_status`** — last run / next run / state for one Hermes cron job by `jobId`, plus a small inline log tail. Read-only — Run/Pause/Resume controls stay on the Cron tab.
- **`image`** — local file (`path` relative to project root) or remote `url`. Optional `height` cap. Useful for matplotlib/Plotly PNGs the cron job generates.
- **`status_grid`** — compact NxM grid of colored cells, one per service / item, with hover labels.
- **`stat` widget gains inline sparklines.** Optional `sparkline: [Number]` field. SVG-only render, dozens per dashboard cost nothing.
- **Typed status badges.** `list` items and `status_grid` cells share a typed enum (`success`, `warning`, `danger`, `info`, `pending`, `done`, `neutral`) with lenient decode for synonyms (`ok`/`up` → success, `down`/`error` → danger). Unknown strings render as plain text.
- **Structured widget error card.** Replaces the legacy "Unknown: \<type\>" placeholder with a card surfacing the title, specific reason, and a hint.
- **Schema mirror.** The widget vocabulary lives once at [`tools/widget-schema.json`](https://github.com/awizemann/scarf/blob/main/tools/widget-schema.json); the catalog validator reads from it and enforces per-type required fields.
---
### OAuth resilience + Credential Pools
- **Daily OAuth keepalive cron.** Prevents Anthropic OAuth refresh tokens from expiring after weeks of inactivity. New cron job `[scarf:oauth-keepalive]` (managed by Scarf) pings Hermes on a daily cadence; the in-app Refresh All Sessions action mirrors the same path on demand.
- **Remote re-auth.** Re-authenticating against a remote droplet's OAuth provider used to be blocked by the lack of a stdin path through SSHTransport. The OAuth flow now drives a remote `hermes auth add` correctly with stdin forwarded.
- **OAuth remove button.** Per-provider remove action in Credential Pools (auth.json edit), with confirmation dialog. Companion auto-refresh of the view when `auth.json` changes externally (file-watcher).
- **`resolve_provider_client` error classification.** When an auxiliary task references a provider whose credentials aren't loaded, Hermes prints `resolve_provider_client: <name> requested but <Display Name> not configured` to stderr — pre-fix this surfaced in chat as the opaque `-32603 Internal error` with no actionable detail. Now classified into a clear hint pointing at Settings → Aux Models.
- **Aux Tab unknown-task surface.** When `config.yaml` has an `auxiliary.<task>` block for a task Scarf doesn't know about (newer Hermes added it; Scarf hasn't caught up), render it as a plain row with the raw provider/model values instead of dropping it silently.
- **Credential Pools refresh after OAuth sheet dismiss.** Closing the OAuth sheet after a successful add now refreshes the list immediately instead of leaving the just-added pool hidden until the next file-watcher tick.
---
### ScarfMon — performance instrumentation harness
The diagnostic surface that drove the bulk of the v2.7 perf work. Off by default; signpost-only mode (Instruments-friendly) is free; Full mode (4096-entry in-memory ring buffer + os.Logger) is a click away in Settings → Diagnostics → Performance. Wiki: https://github.com/awizemann/scarf/wiki/Performance-Monitoring
- **Phases 1-3** built the core: dispatcher + ring buffer + 3 backends, chat / transport / sqlite measure points, diagnostic counters for chat-render bursts, finalize-burst dampening.
- **Tier A + B** added per-feature instrumentation: iOS file watcher, sessions list, model catalog, dashboard widgets, image encoder, message hydration.
- **Nous picker investigation** localized a 60s + 120s beach-ball to a specific path (Nous catalog `readCache`), then killed the 120s one with dedupe + 5s timeout.
- **Tier C catch-up** (this release): instrumented Memory / Skills / Cron / Curator load paths so future captures show how often these tabs cost multiple sequential SFTP RTTs on remote.
- **Per-call bytes recorded** on transport + sqlite events so captures show payload sizes alongside latencies.
- **`mac.emptyAssistantTurn` event** documents the Nous quirk where the model returns a thought stream with no body (the bubble looks like Hermes is "still thinking" but the turn already finished).
Adding a new measure point is two lines. The harness covers Mac and iOS uniformly. The "Copy as JSON" button exports the ring buffer for paste-into-issue diagnosis.
---
### Other fixes + polish
- **Sessions sidebar reload debounce** — file-watcher deltas during streaming used to flicker the sessions list. Coalesced into one trailing fetch ~500ms after the last tick.
- **Session-load pagination + race guard** — switching to a small chat while a larger one is mid-fetch could last-write-wins the small chat away. Three race-checks against `self.sessionId` prevent the stale fetch from overwriting.
- **Sessions + previews batched** — two separate SSH calls folded into one `queryBatch` round trip, halving the round-trips for every sidebar refresh.
- **Remote SQLite query timeout** bumped 15→30s to better tolerate slow links; in-flight query coalescing dedupes concurrent identical queries.
- **`Thread.sleep` spin replaced** with a kernel-wait via `DispatchGroup` for `runLocal` timeout; under concurrent SSH load the old loop accumulated spin-blocked threads and produced 7-second outliers in `loadRecentSessions`.
- **Window position + size** persists across launches.
- **Sidebar reorder** — Projects promoted to first section; profile chip moved under server name.
- **`stop` badge suppressed** on metadata footer for normal turn ends (it was firing for every clean completion, looking like an error).
- **Nous picker search field** + `model-picker` filter for the long Nous overlay model list.
- **`oauth-keepalive` cron create** — drop the `--silent` flag Hermes doesn't accept.
- **Snapshot pipeline rewritten** — replaced the `sqlite3 .backup`-then-download pipeline with direct SSH-streamed query execution (issue [#74](https://github.com/awizemann/scarf/issues/74)). Eliminates the multi-minute snapshot wait on multi-GB state.db files. Companion fix: pre-expand `~/` in Swift via `resolvedUserHome` so sqlite3 finds the DB without depending on the remote shell's tilde expansion.
- **Aux nested-YAML parser** — corrected the parser so the unknown-task surface works on remote (was previously dropping aux blocks whose `provider:` value lived on a separate line).
- **`ModelPreflight` newline trim bug** — `.whitespaces` doesn't strip newlines; switched both trims to `.whitespacesAndNewlines` so a stray `\n` in a hand-edited config.yaml doesn't false-positive the mismatch banner.
---
### What's measured today
321 ScarfCore tests pass (302 prior + 19 new ModelPreflight). New ScarfMon events documented in the [Performance-Monitoring wiki](https://github.com/awizemann/scarf/wiki/Performance-Monitoring).
### Compatibility
@@ -58,4 +142,14 @@ Two items from the design plan stayed deferred:
- Hermes target: still **v2026.4.30 (v0.12.0)**. No new Hermes capability gates added.
- Existing `dashboard.json` files render unchanged.
- Existing `.scarftemplate` bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump.
- The `awizemann/template-author` bundle was rebuilt to ship the updated `SKILL.md` (Widget Catalog v2.7+ section) so Hermes can scaffold dashboards using the new widget types out of the box.
- Existing `~/.hermes/.env` content is preserved byte-identically — Scarf only writes inside its `# scarf-secrets:begin <slug>` / `# scarf-secrets:end <slug>` regions.
- The skeleton-then-hydrate chat loader and SSH cancellation propagation are **Mac-only** in this release; ScarfGo (iOS) keeps its existing chat path.
### What's deferred
- **Per-widget data sources + per-widget refresh granularity.** The general "widget points at a typed data source" abstraction is the next-largest win in dashboards but materially expands the model + JS mirror + validator surface. The project-wide watch covers the common cron-driven workflow without it.
- **Cross-project health digest sidebar rollup.** Counting attention-needed projects across the registry — scoped but didn't pull its weight. The typed status enum makes it cheap to add later.
- **Automatic cron-prompt rewriter on upgrade.** Heuristic rewrites of free-form prompts are risky; the docs + agent-assisted path ships in v2.7. Revisit a "scan + fix" UI in v2.8 if real users miss the migration.
- **iOS New Project wizard + iOS Keychain-env mirror.** ScarfGo's project surface is read-only; the wizard's chat-handoff pattern depends on Mac-only ACP plumbing.
- **iOS skeleton-then-hydrate loaders.** Same data-service surfaces are public, but the iOS chat lifecycle is structured differently. Defer until iOS dogfooding shows the same payload-size pain.
- **Tier C redesigns (Memory/Skills/Cron/Curator).** Instrumented in v2.7; redesign waits for capture data showing which path actually needs the skeleton-then-hydrate treatment.
-93
View File
@@ -1,93 +0,0 @@
## What's in 2.8.0
A focused release on **two project-side gaps** that came up in real use:
1. **Cron jobs can finally use Keychain-backed secrets.** Previously, cron prompts that referenced a `secret`-typed config field got the literal `keychain://...` URI back when reading `config.json`, producing 401s. v2.8 mirrors resolved values into `~/.hermes/.env` under namespaced env-var names, and the bundled skill teaches the agent to reach for them via `$SCARF_<SLUG>_<FIELD>`. Hermes already reloads `.env` per cron tick, so credential rotation is free. ([#75](https://github.com/awizemann/scarf/issues/75)-adjacent.)
2. **New Project from Scratch wizard.** A new toolbar entry that scaffolds a Scarf-standard project skeleton (`<project>/.scarf/dashboard.json` + AGENTS.md marker block), registers it, and hands off to a chat session that activates the bundled `scarf-template-author` skill. The skill drives the substantive setup conversationally — widgets, optional config schema, optional cron — and writes the final files itself.
3. **Bug fix: Configuration form layout recursion** ([#75](https://github.com/awizemann/scarf/issues/75)). Per-stage frame sizes on `ConfigEditorSheet` produced `_NSDetectedLayoutRecursion` for projects whose form transitioned between stages with different intrinsic heights. Fixed by stabilizing the outer frame at the editing stage's intrinsic size so transitions only swap content, never resize the container.
### Cron + Keychain — `$SCARF_<SLUG>_<FIELD>` env vars
Until v2.8, the documented (and broken) pattern for cron prompts that needed a secret looked like:
> *"Read `api_token` from `<project>/.scarf/config.json` and call the API with that as a bearer token."*
Hermes loaded `config.json`, saw `{"api_token": "keychain://com.scarf.template.foo/api_token:abc123"}`, and forwarded the URI as the literal token. The provider returned 401. Hermes has no `keychain://` resolver and doesn't substitute env vars into prompt text — both are intentional design points on the Hermes side.
**v2.8 leans on what Hermes does have**: [`cron/scheduler.py:897-903`](https://github.com/hermes-agent) reloads `~/.hermes/.env` fresh on every cron tick. Anything in that file becomes a real `os.environ` entry the agent can read via the terminal or `code_exec` tool. Scarf now mirrors a project's resolved Keychain values into `~/.hermes/.env` under a marker-bounded block keyed by the template's slug:
```sh
# scarf-secrets:begin local-news-aggregator
SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN=actual-value
SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL=https://example.com/feed
# scarf-secrets:end local-news-aggregator
```
The mirror runs at every state-change point: install, post-install Configuration save, uninstall, "Remove from List", and on app launch (reconciliation pass over registered projects). Source of truth stays in the Keychain — `config.json` keeps `keychain://` URIs unchanged. Mode 0600 enforced on `~/.hermes/.env`, same as the existing `ANTHROPIC_API_KEY` and friends.
**Cron prompts now reference these env vars directly:**
```json
{
"name": "Daily news digest",
"schedule": "0 9 * * *",
"prompt": "Use the terminal: curl -sS -H \"Authorization: Bearer $SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN\" \"$SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL\" -o {{PROJECT_DIR}}/.scarf/feed.xml. Then summarise the top 5 items into {{PROJECT_DIR}}/.scarf/digest.md."
}
```
Naming convention: `SCARF_<UPPER_SLUG>_<UPPER_FIELDKEY>`. Both halves uppercased; non-alphanumerics fold to `_`; leading/trailing/consecutive underscores trimmed. Stable across releases.
#### Migration — existing projects
**Automatic part — no action needed.** On the first launch of v2.8, Scarf walks the project registry and writes a managed block per schemaful project into `~/.hermes/.env`. Idempotent — projects whose values haven't changed produce no write.
**You may need to fix cron prompts you wrote against the old (broken) pattern.** If you have an existing cron job that references a Keychain-backed secret, the prompt will still produce 401s until you update it to use the env-var convention. Two ways:
1. **Manually**, via Scarf's Cron sidebar — open the job, edit the prompt to reference `$SCARF_<UPPER_SLUG>_<UPPER_FIELDKEY>` via the terminal or `code_exec` tool. The bundled `scarf-template-author` skill (now v1.1.0) documents the convention with worked examples.
2. **Via the agent.** With the project loaded in chat, ask: *"Update my Local News cron job's prompt to use the new env var convention."* The skill knows the convention and the project's slug; it'll edit the cron job for you.
We considered an automatic prompt-rewriter on upgrade, but cron prompts are free-form and a heuristic rewrite has a non-trivial chance of breaking custom phrasings. The documented + agent-assisted path is safer for v2.8; we'll revisit a "scan + fix" UI in v2.9 if the docs path doesn't catch users.
#### What about `.env` rotation?
User rotates a secret in Scarf's Configuration sheet → new value lands in the Keychain (via the form's commit step) → Scarf re-mirrors to `~/.hermes/.env` → next cron tick (Hermes reloads `.env` per tick) sees the new value. **No cron-job edit needed.**
### New Project from Scratch wizard
Three project entry points now coexist:
- **Browse Catalog… / Install from File / Install from URL** — install a `.scarftemplate` bundle (existing).
- **Add Project (sidebar `+`)** — register an existing directory by name + path (existing).
- **New Project from Scratch…** — _new_, scaffolds a fresh Scarf-standard project skeleton and hands off to chat for the rest.
The wizard asks for project name, folder name (auto-derived from the name but editable), parent directory, and an optional one-liner about what the project is for. On commit, [`ProjectScaffolder`](../../scarf/scarf/Core/Services/ProjectScaffolder.swift) creates `<parent>/<slug>/.scarf/dashboard.json` (one placeholder text widget) plus a stub `AGENTS.md` (just the Scarf-managed marker block — `ProjectAgentContextService` populates between the markers on first chat). The project is registered in the sidebar before the chat opens.
A new ACP session opens with the project's path as `cwd`, and Scarf auto-sends a kickoff prompt that activates the bundled `scarf-template-author` skill — *"I just created a new Scarf project at /Users/.../local-news. Use the scarf-template-author skill to walk me through configuring it."* The skill drives the substantive setup conversationally: choosing widgets, designing an optional config schema, optionally registering cron jobs, writing AGENTS.md template content. It writes the final `dashboard.json` / `manifest.json` / `AGENTS.md` content in the project directory itself.
The wizard intentionally stays minimal — every "configure" decision lives in the chat handoff, not in the form, because the agent does it better than a multi-step form.
#### Skill bootstrap
The wizard depends on the `scarf-template-author` skill being installed at `~/.hermes/skills/scarf-template-author/`. v2.8 ships a [bundled copy of the skill](../../scarf/scarf/Resources/BuiltinSkills.bundle/scarf-template-author/SKILL.md) inside `Scarf.app/Contents/Resources/BuiltinSkills.bundle/` and copies it into `~/.hermes/skills/` on app launch — idempotent + version-gated, so a user-edited newer destination stays untouched. No Template Author template install required.
### Bug fix — Configuration form layout recursion (#75)
Per-stage frames on `ConfigEditorSheet` (`.loading: 320pt`, `.editing: 480pt`, `.succeeded / .notConfigurable / .failed: 280pt`) caused AppKit to relayout the sheet container mid-flight on stage transitions, producing `_NSDetectedLayoutRecursion` and a blank form. Fixed by stabilizing the outer VStack frame at `560 x 480` (matching `TemplateConfigSheet`'s intrinsic size) so transitions only swap content, never resize the container.
### Schema mirrors
`SecretsEnvBlock` (the marker-block helper for `~/.hermes/.env`) lives in ScarfCore alongside `ProjectContextBlock` (the AGENTS.md helper). Both follow the same shape — `applyBlock` / `removeBlock` operating on bounded marker regions, byte-identity preservation outside the block, idempotent re-apply.
### Compatibility
- macOS 14+ (unchanged).
- Hermes target: still **v2026.4.30 (v0.12.0)**. No new Hermes capability gates added.
- **Mac-only.** ScarfGo (iOS) doesn't have a Keychain-env-mirror story today; the wizard is also Mac-only for v1.
- Existing `~/.hermes/.env` content is preserved byte-identically — Scarf only writes inside its `# scarf-secrets:begin <slug>` / `# scarf-secrets:end <slug>` regions.
- Existing `.scarftemplate` bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump.
### What's deferred
- **Automatic cron-prompt rewriting on upgrade.** Heuristic rewrites of free-form prompts are risky — see "Migration" above for the docs-and-agent path that ships in v2.8. Revisit a "scan + fix" UI in v2.9 if real users miss the migration.
- **iOS New Project wizard + iOS Keychain-env mirror.** ScarfGo's project surface is read-only today; the wizard's chat-handoff pattern depends on Mac-only ACP plumbing.
- **Resolution-layer unit tests.** The `KeychainEnvMirror.mirror(project:)` path that resolves `keychain://` URIs would either pollute the user's login keychain on test runs or require a mock-keychain abstraction; the splice-only seam (`mirror(slug:entries:envPath:)`) is fully unit-tested instead, with the resolution path covered by manual end-to-end verification.
+20
View File
@@ -221,6 +221,23 @@ FILE_LENGTH="$(echo "$SIG_OUTPUT" | sed -nE 's/.*length="([^"]+)".*/\1/p')"
DOWNLOAD_URL="$DOWNLOAD_URL_BASE/v${VERSION}/Scarf-v${VERSION}-Universal.zip"
PUB_DATE="$(LC_TIME=en_US.UTF-8 date -u +"%a, %d %b %Y %H:%M:%S +0000")"
# Render RELEASE_NOTES.md to a Sparkle-friendly inline HTML fragment
# and embed it as <description><![CDATA[…]]></description> on the
# appcast item. Sparkle's standard update alert renders this in a
# WebKit view so users see the release-specific "what's new" inside
# the in-app update sheet, not just the version number. Falls back to
# a placeholder line when the notes file is missing (matches the
# `--notes-file` fallback behavior of `gh release create` below).
RELEASE_NOTES_HTML=""
if [[ -f "$NOTES_FILE" ]]; then
log "Render in-app release notes from $NOTES_FILE"
RELEASE_NOTES_HTML="$(python3 "$REPO_ROOT/tools/render-release-notes.py" "$NOTES_FILE")"
fi
if [[ -z "$RELEASE_NOTES_HTML" ]]; then
RELEASE_NOTES_HTML="<p>Release v${VERSION}. See the <a href=\"https://github.com/awizemann/scarf/releases/tag/v${VERSION}\">GitHub release page</a> for details.</p>"
fi
APPCAST_ITEM=$(cat <<EOF
<item>
<title>Version ${VERSION}</title>
@@ -228,6 +245,9 @@ APPCAST_ITEM=$(cat <<EOF
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<pubDate>${PUB_DATE}</pubDate>
<description><![CDATA[
${RELEASE_NOTES_HTML}
]]></description>
<enclosure url="${DOWNLOAD_URL}"
sparkle:edSignature="${ED_SIGNATURE}"
length="${FILE_LENGTH}"
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Render a release-notes Markdown file into a small standalone HTML
fragment suitable for inlining into a Sparkle appcast `<description>`
element (CDATA-wrapped).
Stdlib only no `markdown` package dependency. Covers the subset of
GitHub-flavored markdown that `releases/v*/RELEASE_NOTES.md` uses:
* `## Heading 2` / `### Heading 3`
* paragraphs (blank-line-separated)
* unordered lists (`- item`, single level only)
* fenced code blocks (` ``` `)
* inline `code`, **bold**, *italic*, `[link text](url)`
* horizontal rules (`---`)
Sparkle's `SUUserUpdateAlertController` renders the inline HTML in a
WebKit view with no styling beyond what's in the body, so a tiny
`<style>` block is included. Fonts and spacing are tuned to look
right inside the standard 480×360 update sheet.
Usage:
python3 tools/render-release-notes.py releases/v2.7.0/RELEASE_NOTES.md > out.html
Used by `scripts/release.sh` to populate the appcast item's
`<description>` block per release.
"""
from __future__ import annotations
import html
import re
import sys
from pathlib import Path
from typing import Iterator
# ---------- inline ----------
_INLINE_CODE = re.compile(r"`([^`]+)`")
_BOLD = re.compile(r"\*\*([^*]+)\*\*")
_ITALIC = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
def render_inline(text: str) -> str:
"""Apply inline transforms in order: escape HTML first, then
swap markdown markers in-place. Order matters links before
bold so `[**bold**](url)` doesn't double-process."""
out = html.escape(text)
out = _INLINE_CODE.sub(lambda m: f"<code>{m.group(1)}</code>", out)
out = _LINK.sub(lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', out)
out = _BOLD.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
out = _ITALIC.sub(lambda m: f"<em>{m.group(1)}</em>", out)
return out
# ---------- block ----------
def render_blocks(lines: list[str]) -> Iterator[str]:
"""Walk lines and emit HTML blocks. Maintains state for fenced
code, lists, and paragraph buffers."""
i = 0
n = len(lines)
paragraph_buf: list[str] = []
list_buf: list[str] = []
def flush_paragraph() -> Iterator[str]:
if paragraph_buf:
text = " ".join(paragraph_buf).strip()
if text:
yield f"<p>{render_inline(text)}</p>"
paragraph_buf.clear()
def flush_list() -> Iterator[str]:
if list_buf:
yield "<ul>"
for item in list_buf:
yield f" <li>{render_inline(item)}</li>"
yield "</ul>"
list_buf.clear()
while i < n:
line = lines[i]
stripped = line.rstrip("\n")
# Fenced code block
if stripped.startswith("```"):
yield from flush_paragraph()
yield from flush_list()
i += 1
code_lines: list[str] = []
while i < n and not lines[i].rstrip("\n").startswith("```"):
code_lines.append(lines[i].rstrip("\n"))
i += 1
i += 1 # skip closing fence
escaped = html.escape("\n".join(code_lines))
yield f"<pre><code>{escaped}</code></pre>"
continue
# Blank line — close paragraph + list
if not stripped.strip():
yield from flush_paragraph()
yield from flush_list()
i += 1
continue
# Horizontal rule
if stripped.strip() == "---":
yield from flush_paragraph()
yield from flush_list()
yield "<hr>"
i += 1
continue
# Heading
if stripped.startswith("### "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h3>{render_inline(stripped[4:])}</h3>"
i += 1
continue
if stripped.startswith("## "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h2>{render_inline(stripped[3:])}</h2>"
i += 1
continue
if stripped.startswith("#### "):
yield from flush_paragraph()
yield from flush_list()
yield f"<h4>{render_inline(stripped[5:])}</h4>"
i += 1
continue
# Unordered list item
list_match = re.match(r"^[-*]\s+(.+)$", stripped)
if list_match:
yield from flush_paragraph()
list_buf.append(list_match.group(1))
i += 1
continue
# Paragraph line — close list, accumulate
if list_buf:
yield from flush_list()
paragraph_buf.append(stripped)
i += 1
yield from flush_paragraph()
yield from flush_list()
# ---------- document ----------
# Sparkle WebKit view default styling is plain — give it enough to
# look like a release notes sheet, not a 1995 docs dump. Sized for
# the standard update alert dimensions.
STYLE = """\
body {
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
font-size: 13px;
line-height: 1.5;
color: #1d1d1f;
margin: 0;
padding: 0 4px;
}
h2 {
font-size: 17px;
margin: 16px 0 6px 0;
border-bottom: 1px solid #e5e5e7;
padding-bottom: 3px;
}
h3 {
font-size: 14px;
margin: 14px 0 4px 0;
color: #424245;
}
h4 {
font-size: 13px;
font-weight: 600;
margin: 10px 0 2px 0;
}
p { margin: 6px 0; }
ul { margin: 6px 0; padding-left: 20px; }
li { margin: 3px 0; }
code {
background: #f5f5f7;
border-radius: 3px;
padding: 1px 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
}
pre {
background: #f5f5f7;
border-radius: 5px;
padding: 8px 10px;
overflow-x: auto;
font-size: 12px;
}
pre code { background: transparent; padding: 0; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
hr {
border: none;
border-top: 1px solid #e5e5e7;
margin: 16px 0;
}
strong { color: #1d1d1f; }
@media (prefers-color-scheme: dark) {
body { color: #f5f5f7; background: #1c1c1e; }
h2 { border-bottom-color: #38383a; }
h3 { color: #c7c7cc; }
code, pre { background: #2c2c2e; }
hr { border-top-color: #38383a; }
a { color: #4499ff; }
strong { color: #f5f5f7; }
}
"""
def render_document(markdown: str) -> str:
body = "\n".join(render_blocks(markdown.splitlines(keepends=True)))
return f"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>{STYLE}</style></head><body>\n{body}\n</body></html>"
def main(argv: list[str]) -> int:
if len(argv) != 2:
sys.stderr.write("usage: render-release-notes.py <RELEASE_NOTES.md>\n")
return 2
path = Path(argv[1])
if not path.exists():
sys.stderr.write(f"file not found: {path}\n")
return 1
sys.stdout.write(render_document(path.read_text(encoding="utf-8")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))