Skip to content

Latest commit

 

History

History
220 lines (157 loc) · 38.2 KB

PROGRESS.md

File metadata and controls

220 lines (157 loc) · 38.2 KB

Progress

Tracks implementation phases against specs/. Updated after each meaningful phase (CLAUDE.md rule 14).

V1

Phase Status Notes
API: firebase.ts done matches specs/v1/02-api-firebase.md
API: algolia.ts done matches specs/v1/03-api-algolia.md (unused until search/comments land)
UI: StoryList (tracer bullet) done feeds top/new/best, pagination, j/k/o/t/n/b/[/], enter opens StoryDetail
UI: StoryDetail done title/domain/metadata, o opens browser, esc/b back, enter/c opens Comments
UI: Comments done flat top-level list + one level of reply drill-in, htmlToText rendering, j/k/enter/esc
Search done hn search <query...> CLI entry, / in-TUI entry, reuses StoryRow, esc back/quit per entry point

V1 is now feature-complete against specs/v1/.

V1.5 — UI overhaul (specs/v1.5/)

Inspired by heartleo/hn-cli: fullscreen TUI, tab bar (5 feeds), continuous scroll, inline fold/unfold comment tree. Sanctioned keybinding break (see specs/v1.5/06-keybindings.md).

Phase Status Notes
Specs done specs/v1.5/00–07 committed; specs/README.md ground rule amended
1: Fullscreen shell done alt screen (TTY-only), Layout (Screen/Header/Body/Footer), theme.ts, vendored Ink test harness, q-in-search-input bug fix + regression test
2: Tabs + continuous list done ask/show feeds, viewport.ts (ensureVisible/visibleSlice/shouldFetchMore), TabBar in header, StoryList rewrite (continuous scroll, progressive 30-item batch fetch, request-token guard), StoryDetail deleted (enter opens comments directly), StoryRow pre-truncates title to width
3: Comment tree done commentTree.ts (flattenTree/toggleFold/collapseAll/expandAll), viewport.ts gains wrapPlainText/ensureVisibleLines/sliceByLines for variable-height windowing, Comments.tsx rewritten as single inline fold/unfold tree with metadata header, comments.ts loses flattenSubtree (drill-in deleted)
4: Search + help overlay done SearchResults.tsx rewritten onto StoryListView + progressive fetch (Algolia hasMore, request-token guard, totalHits in header); ]/[ paging removed. SearchInput renders as a footer-line prompt (/ query▊), hosted by App.tsx. src/ui/keymap.ts is the single {key,label}[] source driving both footer hints and the new ? HelpOverlay.tsx; q/? both suppressed while search input is focused.
5: Reconcile + polish done specs/README.md V1.5 marked done, root README rewritten (fullscreen usage + full V1.5 keymap), PR raised
6: Theme palettes done Ported heartleo/hn-cli's colors.go palettes into theme.ts: 6 named ansi256 themes (hn default, mocha, dracula, tokyo, nord, gruvbox); resolveTheme/resolvePaletteName (explicit name > HN_THEME env var > hn, unknown falls back). StoryRow gets color for the first time (title/score/comment/muted) and fixes the emoji-glyph spec drift from phase 1 (⯅/💬 → theme.glyphs.points/.comments). --theme <name> CLI flag + hn theme info command; no persistence (config file still deferred to V2)

V1.5 is now feature-complete against specs/v1.5/.

V1.6 — Visual rework (specs/v1.6/)

Two-line story rows + comments view redesign (bordered cards, collapsed-by-default threads), requested against a mockup with no prior spec coverage.

Phase Status Notes
Specs done specs/v1.6/01-02 committed; specs/README.md + PROGRESS.md updated
1: Story row layout done theme.ts gains selectionBackground (shared across palettes) and glyphs.upvote, drops points/comments glyphs, selection glyph is now >; StoryRow renders the two-line card (title + hostname on row 1, score/author/age/comments on row 2, row 1 highlighted full-width on selection); StoryList/SearchResults move from ensureVisible/visibleSlice to ensureVisibleLines/sliceByLines, and StoryListView clips a partial edge row down to its title line, never its meta line
2: Comments rework done CommentsHeader is a bordered card (title / score-author-age-comments / url); tree is collapseAll'd on load instead of starting expanded; buildRow no longer short-circuits a folded node's body, and every node with replies shows a right-aligned "N replies"/"1 reply" badge regardless of fold state; the selected comment renders inside a bordered card instead of inverse highlighting
3: Fix footer-wrap bug found during live verification done Layout.FOOTER_ROWS was hardcoded to 1, but the footer hint text actually wraps to 2 rows at common terminal widths — with the new 2-line story rows that 1-line miscount corrupted the whole list (meta text bleeding into title lines). Replaced with keymap.footerRows(keys, width) (via wrapPlainText); dead FOOTER_ROWS constant removed. Confirmed live under tmux at 80/100/140 columns — pre-existing bug, not new to V1.6, just newly visible once row height stopped being 1.
Polish specs (from demo-browse.gif review) done specs/v1.6/03-05 committed: tab bar redesign (brand + tab-notch active box + ? help, header 3 rows), story list polish (right-aligned ranks, blank separator line, selection covers both lines, ❯ marker), animated braille loading indicator
4: Tab bar redesign done matches specs/v1.6/03-tab-bar.md; Layout.HEADER_ROWS 1→3, TabBar rewritten to build 3 plain-string rows (top border / brand+tabs+? help / notch rule) with the active tab's box walls tracked by column so the rule notch aligns exactly under it at any width; verified live via tmux at 100 and 70 columns switching tabs
5: Story list polish done matches specs/v1.6/04-story-list-polish.md; STORY_ROW_HEIGHT 2→3 (blank separator line), rank right-aligned to String(stories.length).length via new rankWidth prop, theme.glyphs.selection >→❯, selection background now covers both title and meta lines (full width); StoryListView clip priority reworked to line-count-based (separator drops first, then meta, title always last standing)
Bugfix: selected-row content reaching exact terminal width corrupts the row below done title/hostname (or, for short titles, the selection-highlight padding) could sum to exactly the terminal width; that triggers a VT100 delayed-wrap that Ink's cursor math doesn't account for, silently dropping the next row's background paint. Fixed by reserving 1 trailing column (safeWidth = width - 1) for both truncation and padding math in StoryRow. Root-caused via tmux capture-pane -e byte-level diffing across ~20 isolated repros (ruled out glyph unicode-width, rerender/diffing races, and rank-width churn before isolating it to exact-width-fill); regression test in StoryRow.test.tsx (verified red without the fix, green with it)
6: Loading indicator done matches specs/v1.6/05-loading-indicator.md; new shared LoadingIndicator.tsx (braille spinner, 80ms/frame, useState+useEffect interval, cleaned up on unmount) replaces the bare loading… text in StoryList, Comments, SearchResults with per-call-site labels ("Loading stories...", "Loading comments...", "Searching..."); loading more… footer line left as plain text per spec. Verified live via tmux (spinner glyph + label visible on cold start and on opening comments)
Comments polish specs (from expected-view mockups) done specs/v1.6/06-comments-polish.md committed: tab-notch color consistency, comment header colors + inline [N more]/N replies badge, header-only fold state with C/E rework (full-tree expand removed), URL/email highlighting (shared link/email semantic colors), selection left-bar + background stripe
7: Comments polish done matches specs/v1.6/06-comments-polish.md; TabBar notch (╭─╮/│ walls/╯╰) renders fully in accent, rest stays dimColor; commentTree.ts gains a second headerOnly: Set<id> alongside folded, flattenTree takes both, collapseAll/headerOnlyAll replace expandAll (C → header-only every node, E → reset to default collapsed, space/enter reveals a header-only row without touching its children's state); comment rows render as accent-glyph/bold-author/muted-age/muted-badge spans with an inline [N more]/N replies badge (theme.ts unaffected — colors are per-span, not new theme keys); new src/lib/contactHighlight.ts (tokenizeContacts) colors bare URLs/emails found by htmlToText using two new shared theme colors (link/email); selection is now an accent ▌ bar + selectionBackground stripe (bordered-card selection from phase 2 removed)
Bugfix: long story URL corrupts the comments frame on load done Comments.tsx's headerLines constant assumed the bordered header card is always 3 interior lines, but a URL longer than the card's interior width word-wraps to 2 lines; undercounting that let viewportLines render one comment row too many, overflowing the terminal and triggering a real-TTY auto-scroll that desyncs Ink's cursor-relative redraw (title row and the first comment's header line got overwritten/dropped — invisible in the ink-testing-library harness since FakeStdout doesn't emulate real cursor movement, only reproduced live via tmux capture-pane -e on a story with a long URL). Replaced the hardcoded constant with commentsHeaderLines(), which wraps the title and url through wrapPlainText at the card's actual interior width. Regression test in Comments.headerLines.test.ts (verified red against the old hardcoded formula, green with the fix); confirmed live via tmux at 80/100/140 columns.
Phase 8 specs (from screenshot review 2026-07-09) done specs/v1.6/07-navbar-selection-fold.md committed: full-accent tab-bar bottom rule (supersedes 06 §1 color split), unbroken selection bar (exact-width delayed-wrap, safeWidth reserve as in the StoryRow exact-width fix above), C-mode leaf reveal becomes header↔body toggle via tracked revealed set (also codifies shipped two-step reveal that 06 §3 described as one press). "Load more" hint line considered and dropped during spec grilling
8: Navbar rule, selection bar, leaf collapse done matches specs/v1.6/07; TabBar.buildBottomRuleSegments now emits the whole rule (dashes + ╯ ╰ notch) as one accent segment; Comments.buildRow reserves 1 trailing column (columns - 1, same safeWidth treatment as StoryRow) so a selected row's bar/stripe never hits the exact-width VT100 delayed-wrap — regression test in Comments.test.tsx uses a 300-char unbroken body (padding-only probes don't work: unstyled trailing spaces are trimmed in the test harness, only real content reaches the edge), verified red with fix temp-reverted, green with it; commentTree.ts revealHeaderOnly now moves ids into a tracked revealed set (new RevealState) and new rehideRevealed moves a leaf back, wired in Comments.toggleSelected (revealed leaf toggles back to header-only; default-view leaves stay no-op; parents keep child-fold toggle), C/E/r reset the set. Verified live via tmux at 100 cols: full-accent rule (single 208-color segment), bar+stripe unbroken across wrapped/blank lines of a long selected comment, C-mode leaf header↔body toggle both directions, parent two-step reveal intact

Spec reconciliation — v2/v3 vs shipped v1.6 (2026-07-09)

v2/v3 specs were written against original V1, before v1.5/v1.6 shipped. Audit found data-layer contracts (Story, CommentNode, fetchComments, lib/html.ts) intact, UI anchors stale. Both spec sets rewritten to the v1.6 baseline:

Change Where
s summary + a Ask AI relocated off deleted StoryDetail: s in list = article (text posts: post text, else thread fetch + notice), s in comments = thread; a from list (fetches thread on demand) or comments v2/00, 04, 05
ai/context.ts added to module tree; Story.text? + keymap.ts touch points noted v2/00
Bookmarks: 6th Saved tab in TabBar (hn bookmarks opens on it), reuses StoryListView + continuous scroll (paging refs deleted), ★ moves to meta line, B in list + comments, mockup redrawn to 3-line rows / notch header / keymap footer v3/00, 05

Spec revision - v3 subscriptions TUI + Saved s/a parity (2026-07-12)

Grilling session on v3 UX. Decisions and spec changes (no code):

Change Where
Subscriptions get a TUI: 7th Subs tab = manager (list/add/edit/delete, no unread counts), enter = per-topic matches (live Algolia, fixed 7-day window, full s/a/B key parity), add/edit form with debounced live preview, S in search results = subscribe-from-search. CLI hn sub stays; both share db/subscriptions.ts (gains updateSubscription) new v3/06; v3/00, 01, 02
Saved tab gains s/a on the list itself via a SavedList container around StoryListView (bare StoryListView has no useInput, so the original spec silently lacked summary/Ask AI there) v3/05, 00
TUI never touches seen_items/lastRunAt: watcher owns notification state, TUI browsing is stateless fetch. Unread counts explicitly out of V3 v3/00, 06

Spec restructure - V3 split into slices (2026-07-12)

V3 slimmed to its radar core (subscriptions CLI+TUI, watcher, Telegram, SQLite, bookmarks); optional channels extracted, plus a new small slice for theme persistence. No code.

Change Where
macOS desktop notifications (alerter binary, sh wrapper, best-effort/at-most-once semantics, exit-2 gate widening) extracted to V3.5; v3 watcher/notifications/overview now telegram-only with pointers new v3.5/01; v3/00, 03, 04
Discord extracted from "out of V3, interface-ready" to V3.6: webhook transport (not bot API), discord.{enabled,webhookUrl} config (webhookUrl masked), telegram failure semantics new v3.6/01
Theme persistence spec'd as V3.1: ui.theme config key validated against paletteNames(), precedence flag > env > config > default, hn theme shows source new v3.1/01
specs/README status table: V2/V2.5 marked done (were stale/missing), rows added for V3.1/V3.5/V3.6 specs/README.md

V2 — Local AI (specs/v2/)

Summaries (s) and Ask AI (a) grounded in the story's article + comment thread, streamed from a local Ollama instance. No cloud calls, no API keys.

Phase Status Notes
1: Config + Ollama client done lib/config.ts ($HN_BITS_CONFIG or ~/.config/hn-bits/config.json, deep-merge defaults, invalid JSON warns and degrades to absent — never crashes); ai/ollama.ts (chatStream NDJSON streaming over /api/chat, checkOllama health probe via /api/tags, typed error taxonomy OllamaDownError/ModelMissingError/OllamaError/TimeoutError with a shared describeError hint renderer, 60s idle timeout, clean exit on caller abort). Verified live against a running Ollama (llama3.2): health check + streamed chat
2: Article extraction done lib/article.ts: @mozilla/readability + jsdom (lazily imported so plain-reader startup stays fast), 15s timeout, 2MB response cap, ExtractionError reasons (fetch-failed/not-html/unreadable), 16k-char truncation at a paragraph boundary. Verified live against a real article URL
3: Summaries (s) done ai/context.ts (buildThreadContext: depth-≤2 replies, 1k-char per-comment cap, 12k-char thread budget, always includes at least one top-level comment); ai/summaryPrompts.ts (list-mode fallback chain: article → post text → thread, each with a notice; comments-mode summarizes the already-loaded tree); ui/SummaryPanel.tsx (bordered streaming panel, setup-hint/preparing/thinking/streaming/done/error states, esc close · s regenerate · j/k scroll); wired into StoryList, SearchResults, Comments. Story gains optional text? for the text-post fallback. Verified live via tmux: article summary (with real extraction + truncation notice) and thread summary both streamed correctly
4: Ask AI (a) done ai/context.ts gains buildAskAIContext (story metadata + article text + trimmed thread, reusing buildThreadContext); ui/AskAI.tsx full-screen chat view — health check on open (down/model-missing render as an immediate hint), article extraction with post-text/unavailable-reason fallback, thread fetched on demand from the list (reused as-is when opened from Comments), multi-turn history sent as [system+context, ...history, new turn], esc contextual (abort mid-stream vs. leave when idle), ctrl+c quits. App.tsx gains an ask view (NonAskView-typed returnTo); global q/? step aside for it so keystrokes reach the chat input. Verified live via tmux: multi-turn Q&A grounded in both the extracted article and real thread sentiment, esc returned cleanly to the list

V2 is feature-complete against specs/v2/. Not yet done: everything in v2/00's "Out of V2" list (bookmarks/subscriptions/watcher/notifications/SQLite are V3; no cloud providers; no chat/summary persistence).

V2.5 — Config CLI (specs/v2.5/)

Small slice between V2 and V3: hand-editing nested JSON for a Telegram bot token is worse than a few CLI commands, so this ships ahead of the rest of V3.

Phase Status Notes
1: hn config get/set/unset/list done matches specs/v2.5/01-config-cli.md; Config interface (lib/config.ts) gains optional telegram.{enabled,botToken,chatId} and desktopNotifications.{enabled,timeoutSeconds} (timeoutSeconds defaults to 10 only when the section is present), configPath() exported; new lib/configKeys.ts (whitelisted CONFIG_KEYS schema, parseValue/formatValue with masking for sensitive keys) and lib/configStore.ts (readRawConfig/writeRawConfig — first write path this app has had, getConfigValue/setConfigValue/unsetConfigValue/listConfigEntries); wired into src/index.tsx as a headless config subcommand (hn theme pattern — plain console.log, no Ink). Reverses specs/v2/01-config.md's "no hn config subcommand" decision; specs/v3/04-notifications.md updated so Telegram activation is explicit telegram.enabled (symmetric with desktopNotifications.enabled) rather than bare section presence. Verified: npm test (24 new/updated tests across config.test.ts/configStore.test.ts, 190 total passing), npm run build, and live CLI round-trips (set/get/unset/list, masking, unknown-key/bad-type/missing-arg all exit 1)

V2.5 is feature-complete against specs/v2.5/.

V3 — Radar (specs/v3/)

Subscriptions (CLI + subs tab TUI) + watcher + Telegram + SQLite + bookmarks.

Phase Status Notes
1: SQLite storage done better-sqlite3, WAL mode, $HN_BITS_DB override (default ~/.local/share/hn-bits/hn-bits.db); db/db.ts (openDb/resetDbCache, PRAGMA user_version migrations, cached handle per resolved path); db/subscriptions.ts, db/seen.ts, db/bookmarks.ts — thin prepared-statement modules, no ORM. Matches specs/v3/01-storage.md schema exactly
2: hn sub CLI done add/list/rm Commander subcommands in src/index.tsx, headless (plain console.log); duplicate-name and not-found both exit 1
3: Bookmarks done toggleBookmark wired into StoryList/SearchResults/Comments/new SavedList.tsx via B; new useFlash.ts hook drives the transient "bookmarked ✓"/"bookmark removed" line (reserves 1 status row, same pattern as loading more…); StoryRow shows a ★ prefix (new theme.glyphs.bookmark) on the meta line. 6th Saved tab added to TabBar/App; ←/→ tab cycling generalized from Feed-only to lib/listNavigation.ts's TabId (nextFeed/previousFeed → nextTab/previousTab, TAB_ORDER gains 'saved'). hn bookmarks opens the TUI on Saved. New src/test/dbHarness.ts (useTempDb) keeps every story-list test off the real user DB
4: Notifier + Telegram done notify/notifier.ts (Match/Notifier interface — later channels are one-file additions); notify/telegram.ts (HTML sendMessage, escaped title/author, story link omitted for text posts, single 429 retry honoring retry_after, NotifyError otherwise)
5: Watcher done api/algolia.ts gains searchRecent (search_by_date + numericFilters, shared hitToStory mapping with searchStories); src/watch.ts — sequential per-subscription pass, max(lastRunAt-6h, 0) / now-24h first-run window, dedup via seen_items before send, markSeen only after a successful send, touchLastRun only on query success, --dry-run full pipeline zero writes; exit 0/1/2 and log format per spec. hn watch --once [--dry-run] in src/index.tsx (--once Commander-required)
6: Subscriptions TUI done 7th Subs tab (TAB_ORDER gains 'subs'): SubscriptionsView.tsx (manager — local list, enter matches, a/e/d add/edit/delete with inline y/n confirm), SubscriptionMatches.tsx (fixed 7-day window via searchRecent, full story-list key parity incl. B/s/a), SubscriptionForm.tsx (tab-cycled fields, 300ms debounced live preview, inline validation reusing addSubscription/updateSubscription's own duplicate-name check). App.tsx View gains sub-matches/sub-form (sub-form.returnTo: View, suppresses global q/? like Ask AI); Comments' returnTo widened to a new StoryOriginView union so comments opened from matches return there on esc. S in search results opens the add form prefilled with the query (focus on name). hn subs opens the TUI on Subs
7: Cron auto-install done src/lib/schedule.ts (hasScheduledJob/installScheduledJob/removeScheduledJob, wraps crontab -l/crontab -, marker comment # hn-bits watch); hn sub add prompts to install on the first subscription if no job exists yet; hn schedule status/install/remove for manual management. hn watch --once itself unchanged. Matches amended specs/v3/03-watcher.md scheduling section. Verified live: install/status/idempotent-reinstall/remove against real crontab (frappe-erp entries untouched, restored identical to backup after test), and the first-sub add-prompts/second-add-doesn't fork tested via an isolated $HN_BITS_DB
7b: Cron auto-install, TUI parity done Original CLI-only prompt missed the subs-tab add path (SubscriptionsView's a key / search's S → SubscriptionForm.tsx, which calls addSubscription directly, bypassing src/index.tsx). SubscriptionForm.tsx now runs the same first-subscription/no-schedule check after a successful add, and — since it can't block on stdin like the CLI's readline prompt inside Ink's raw-mode input loop — shows an inline y/n confirm line (same pattern as SubscriptionsView's existing delete confirm) before calling onSave. 3 new tests in SubscriptionForm.test.tsx (prompts when uninstalled, installs on y, skips on n/skips prompt entirely when already installed), ../lib/schedule.js mocked module-wide same as watch.test.ts's pattern
7c: Cron toggle + status indicator, subs tab done The add-form prompt was still one-time only, with no way to check/toggle afterward except the CLI. SubscriptionsView.tsx gains a c key (SUBS_KEYS in keymap.ts, additive) calling installScheduledJob/removeScheduledJob directly (reversible toggle, no confirm — unlike delete), plus a persistent schedule: installed (every 30 min) / schedule: not installed status line (both the populated list and empty-subscriptions states) and a transient useFlash confirmation/failure message on top. Viewport math (statusLineHeight/listHeight) updated since the status line is always-on, not conditional like the delete confirm. 6 new tests in SubscriptionsView.test.tsx, ../lib/schedule.js mocked the same way as SubscriptionForm.test.tsx (now required repo-wide for this file since every test mounts the component and would otherwise hit a real crontab -l)

V3 is feature-complete against specs/v3/. Verified per-phase live via tmux (bookmarks: star display, flash timing, Saved tab CRUD; watcher: all three exit codes against real Algolia, --dry-run, --once enforcement; subs TUI: manager, add-with-preview cross-checked against a direct Algolia query, matches browsing, bookmark toggle, delete confirm, and the full search → S → save → back-to-search-results loop) plus npm test (261 tests) and npm run build after every phase.

V3.1 — Theme persistence (specs/v3.1/)

New ui.theme config key, additive to V2.5's hn config CLI.

Phase Status Notes
1: ui.theme config key done Config.ui?.theme added (lib/config.ts, passed through as-is like telegram); CONFIG_KEYS gains ui.theme with a validate hook on ConfigKeyDef (lib/configKeys.ts) checked against paletteNames(), unknown theme 'x' (valid: ...) on a bad set; configStore.ts's resolveValue generalized off its 'telegram' | 'desktopNotifications' union to include 'ui'. theme.ts's resolvePaletteName gains the config lookup between env and default (flag > HN_THEME env > ui.theme config > hn default), new resolvePaletteSource() reports which level won for hn theme's status line ((from flag/env/config) / (default)); an invalid persisted value resolves to hn same as an invalid env var today. hn theme output and hn config set/get ui.theme verified live

V3.1 is feature-complete against specs/v3.1/.

V3.2 — Live in-TUI theme picker (specs/v3.2/)

Change theme from inside a running session, no restart.

Phase Status Notes
1: reactive theme done Theme was a module-scoped singleton (theme.ts's export const theme = resolveTheme()) read by 13 components at import time — turned reactive via a new ThemeContext/useTheme() pair; all 13 importers switched from the static import to the hook. Comments.tsx's buildHeaderSpans/tokenToSpan/buildRow and TabBar.tsx's segmentColor (module-level pure functions, can't call hooks) take theme as an explicit parameter instead. No behavior change on its own — ThemeContext's default value is the same singleton every non-App render already saw
2: picker overlay done T (new GLOBAL_KEYS entry) opens ThemePicker.tsx — SubscriptionsView-style list over paletteNames(), cursor opens on the active theme, j/k/arrows move, esc cancels. App.tsx owns paletteName/themePickerOpen state, resolves activeTheme each render, provides it via ThemeContext.Provider; picker takes the same overlay slot as HelpOverlay. New THEME_PICKER_KEYS footer hint. Adding a global key lengthens every footer hint enough to wrap an extra line at 80 columns — bumped Comments.test.tsx's fixed test terminal height by one row to keep existing assertions valid
3: persistence done enter in the picker calls setConfigValue('ui.theme', name) (reused as-is from V3.1) alongside the live setPaletteName, so the choice survives a restart; esc writes nothing. Verified live via tmux: T → navigate → enter recolors the whole UI immediately (confirmed via ANSI accent codes matching the target palette)

V3.2 is feature-complete against specs/v3.2/.

V3.3 — concord theme (specs/v3.3/)

7th palette, hand-ported from chojs23/concord's ratatui color constants (not an Ink library — colors pulled from its Rust source and translated to hn-bits' ansi256 format).

Phase Status Notes
1: concord palette done New entry in theme.ts's palettes object: accent ansi256(44) (cyan, from Color::Cyan), title ansi256(255), muted ansi256(243) (from Color::DarkGray), error ansi256(196) (from Color::Red), comment ansi256(80); score/selectionBackground/link/email reuse the shared invariants every other palette already uses. No plumbing changes — paletteNames(), resolvePaletteName(), and configKeys.ts's validateTheme all derive from the object's keys. Also fixed a latent test-isolation gap in theme.test.ts: the "nothing set" default tests read the real ~/.config/hn-bits/config.json whenever HN_BITS_CONFIG was left unset, which broke once a real ui.theme value existed on disk from live-testing V3.2

V3.3 is feature-complete against specs/v3.3/.

V3.7 — Min-comments subscription threshold (specs/v3.7/)

Second, independent minComments threshold OR'd with the existing minPoints floor.

Phase Status Notes
1: minComments end-to-end done Additive migration (db/db.ts MIGRATIONS[1]: ALTER TABLE subscriptions ADD COLUMN min_comments INTEGER NOT NULL DEFAULT 0); Subscription.minComments, addSubscription/updateSubscription thread it through (db/subscriptions.ts). New lib/subscriptionLabel.ts (thresholdLabel — any/≥N pts/≥N cmts/≥N pts or ≥N cmts, shared by hn sub list, SubscriptionsView, and SubscriptionForm's preview). api/algolia.ts's searchRecent gains minComments: single-threshold subscriptions still push one server-side numericFilters entry unchanged. watch.ts and SubscriptionMatches.tsx pass sub.minComments through with no other changes (single change point, per spec). CLI: hn sub add --min-comments <n> mirrors --min-points. TUI: SubscriptionForm.tsx gains a 4th tab-ordered min comments field, live preview label reflects the OR
1a: fix OR-mode undercount done Live-caught via the TUI form preview: first cut fetched one unfiltered page then filtered client-side (passesThreshold), which undercounted badly for high-volume queries (Claude Code: >=30 pts OR >=1 comment returned 2 matches instead of 5+) — brand-new zero-engagement posts crowded the older qualifying stories out of the unfiltered top-50-by-recency window before the OR check ran. Replaced with two parallel server-filtered requests (points>=N, num_comments>=M, each hitting the same 50-hit cap the single-threshold path already uses), unioned by story id, sorted by recency, trimmed to the caller's limit. lib/subscriptionMatch.ts/passesThreshold removed (no longer needed — each side is server-filtered, not client-scanned). Spec's "why client-side" rationale corrected in specs/v3.7/01-min-comments-threshold.md
1b: switch to native Algolia OR done 1a's two-request-merge was itself an unnecessary workaround: the spec had ruled out Algolia's nested-array numericFilters OR syntax ([a, [b, c]] = a AND (b OR c)) as "unverified against the live endpoint," but it verified live against hn.algolia.com this phase (numericFilters=["created_at_i>X",["points>=30","num_comments>=1"]] returned the correct OR-matching set for a real OpenAI query). searchRecent now sends one request in all cases — a flat comma-joined numericFilters string when 0-or-1 threshold is active (unchanged), a JSON-encoded nested-array string when both are active — matching 1a's output exactly (re-verified live against Claude Code: >=30 pts OR >=1 comment) with no extra round-trip. mergeByRecency and the two-request branch removed; spec's rationale rewritten again to record the final, verified approach

V3.7 is feature-complete against specs/v3.7/. npm test (272 tests) and npm run build after each phase; all three OR-mode approaches live-verified against the real Algolia endpoint (Claude Code, OpenAI queries) before landing on 1b's single-request native-OR as final.

V3.5: Desktop notifications (specs/v3.5/)

Second Notifier: macOS desktop notifications via the external alerter binary, alongside V3's telegram.

Phase Status Notes
Spec amendment done Missing-binary semantics made explicit (gap found during pre-implementation analysis): desktop-only config + alerter absent from PATH = one stderr warning, exit 0, no state touched (no markSeen/touchLastRun), so matches survive until install. Telegram-also-enabled runs proceed telegram-only
1: notify/desktop.ts done findAlerter (PATH scan), createDesktopNotifier (null + warning when missing). send spawns a detached, unref'd sh -c wrapper (stdio ignored) that waits out the notification and opens the story URL (story.url ?? hnItemUrl) unless the result is @TIMEOUT/@CLOSED; resolves at spawn, spawn errors log instead of rejecting (best-effort, never blocks markSeen). Story data reaches the shell only as positional parameters: a title containing "$(rm -rf ~)" never lands in the script string (regression-tested)
2: watcher integration done buildNotifiers returns { notifiers, desktopSkipped }; desktop appended when desktopNotifications.enabled. Exit-2 gate widened to "no notifier enabled at all" with both config hints in the message; desktop-only + missing binary short-circuits to exit 0 before touching subscriptions. Telegram stays first in the send order and keeps its at-least-once throw semantics
3: live verify done brew formula build blocked by outdated CLT, so installed the v26.5 release binary to ~/.local/bin. Two spec drifts caught live on macOS 26: (a) alerter's Swift rewrite accepts only double-dash flags (single-dash exits 64), output tokens unchanged; (b) cron runs with PATH=/usr/bin:/bin, which contains neither the Homebrew prefix nor ~/.local/bin: discovery now also scans /opt/homebrew/bin, /usr/local/bin, ~/.local/bin and the wrapper invokes the discovered absolute path. Verified against a sandboxed config/DB (HN_BITS_CONFIG/HN_BITS_DB): real notifications delivered desktop-only (7 matches, exit 0, instant watcher exit with wrappers detached), rerun = "no new matches" (markSeen persisted), no-notifier exit 2, missing-binary exit 0 with seen/lastRunAt untouched, cron-PATH run delivered via absolute path; wrapper click/timeout branches proven with stub alerter/open (click opens URL, timeout doesn't, hostile title inert)

| Live polish (user-verified) | done | Two more live findings: (a) macOS silently drops notifications when the impersonated sender (Terminal.app) lacks notification permission: alerter still prints @TIMEOUT/exit 0, undetectable from the watcher; one-time System Settings grant documented in README + spec. (b) per-sub --group made same-run matches replace each other (collapsed stack opened only the newest URL): group is now hn-<subId>-<storyId>, each match keeps its own clickable notification. Both verified by re-triggering real matches (backup + seen-row delete + last_run_at reset for one sub, reviewer-agent-validated, rolled forward cleanly): two separate banners, each opening its own story URL |

V3.5 is feature-complete against specs/v3.5/. npm test (322 tests) and npm run build after each phase.

V3.8 — Merge multi-subscription matches into one notification (specs/v3.8/)

A story matching multiple subscriptions in one run fired one duplicate notification per subscription.

Phase Status Notes
1: merge into one notification per story done notify/notifier.ts's Match.subscription → subscriptions: Subscription[]; telegram.ts/desktop.ts join names in the header (escapeHtml'd for Telegram, raw for desktop, since each notifier owns its own formatting per the spec's array-not-label decision). watch.ts split into phase A/B: collectPending fetches every subscription and accumulates a Map<storyId, {story, subscriptions[]}>, dispatchPending sends one notify per story (time-ascending) and markSeens every matching sub on success. Desktop --group narrowed from hn-<subId>-<storyId> to hn-<storyId> (notification identity is now per-story, superseding V3.5's per-sub-group live-polish fix). touchLastRun contract unchanged (per sub, iff fetch succeeded, even on a later send failure). 8 new/updated tests across watch.test.ts, telegram.test.ts, desktop.test.ts; regression-tested by temp-reverting the merge (confirmed 2 sends instead of 1, then restored). Live-verified: hn watch --once --dry-run against two subscriptions on the same query against the real Algolia endpoint — every one of 49 overlapping stories logged as a single would notify: [SubOne, SubTwo] ... line

V3.8 is feature-complete against specs/v3.8/. npm test (327 tests) and npm run build.

V3.9 — Topic-less subscriptions + always-24h watcher window (specs/v3.9/)

Numeric-only subscriptions (e.g. points >= 250 OR comments >= 100 with no topic) were blocked by input validation only; the watcher also missed stories crossing a threshold more than ~6h after creation.

Phase Status Notes
1: optional query (CLI + TUI + display) done sub add <name> [query...] with guard (empty query + zero thresholds rejected, exit 1); SubscriptionForm validates "query or a threshold is required" and runs the live preview for an empty query once a threshold is set; new queryLabel in subscriptionLabel.ts renders (any) in sub list, SubscriptionsView, and SubscriptionMatches header. Matching needed no changes: Algolia treats empty query as match-all and searchRecent already OR's the thresholds server-side
2: always-24h window + hitsPerPage 100 done windowStart(now) returns now-24h unconditionally (SIX_HOURS overlap dropped; per-(story, sub) seen_items rows, never pruned, block re-notification); fetchMatches passes hitsPerPage: 100 since a broad sub can exceed 50 hits/day (newest-first, so truncation only drops oldest already-seen stories). Regression-tested by temp-reverting to a 6h window (window test failed, restored). Live-verified: hn watch --once --dry-run on a temp DB with hot (no query, ≥250 pts or ≥100 cmts) and india — hot matched across all topics including a 138-pt story in on comments, india matched keyword hits

V3.9 is feature-complete against specs/v3.9/. npm test (335 tests) and npm run build.

Known gaps / follow-ups

  • V1.6 phases 1–8 complete; V1.6 is feature-complete against specs/v1.6/.
  • V2 phases 1–4 complete; V2 is feature-complete against specs/v2/.
  • V2.5 phase 1 complete; V2.5 is feature-complete against specs/v2.5/.
  • V3 phases 1–6 complete; V3 is feature-complete against specs/v3/.
  • V3.1 phase 1 complete; V3.1 is feature-complete against specs/v3.1/.
  • V3.2 phases 1–3 complete; V3.2 is feature-complete against specs/v3.2/.
  • V3.3 phase 1 complete; V3.3 is feature-complete against specs/v3.3/.
  • V3.7 phase 1 complete; V3.7 is feature-complete against specs/v3.7/.
  • V3.5 phases 1–3 complete; V3.5 is feature-complete against specs/v3.5/.
  • V3.8 phase 1 complete; V3.8 is feature-complete against specs/v3.8/.
  • V3.9 phases 1–2 complete; V3.9 is feature-complete against specs/v3.9/.
  • Next: V3.6 (Discord webhook notifications).