diff --git a/README.md b/README.md
index cb2c972..78d8397 100644
--- a/README.md
+++ b/README.md
@@ -8,551 +8,141 @@
|_| |_|\___/ \___/|_|\_\ \_/\_/ |_|___/\___|
```
-**Config-driven hook framework for Claude Code**
-
-Guard rails, coaching, analytics, and an interactive TUI -- all from one YAML file.
+**One YAML file to guard, coach, and observe your Claude Code sessions.**
[](https://www.npmjs.com/package/hookwise)
[](https://github.com/vishnujayvel/hookwise/actions)
-[]()
[](LICENSE)
-[](https://nodejs.org)
-[](https://www.typescriptlang.org/)
-
-
-
----
-
-## The Problem
-
-You are pair-programming with Claude Code. It is 2am, you are in the zone, and your AI just ran `rm -rf /` while you were reading its previous output. Or it force-pushed to main. Or it has been "refactoring" the build system for 90 minutes while you assumed it was working on the feature.
-
-Claude Code has a [hooks system](https://docs.anthropic.com/en/docs/claude-code/hooks) -- shell commands that fire on 13 different hook events. But writing hook scripts by hand means a pile of bash files, no consistency, and no way to share what works.
-
-**hookwise** is one YAML file. Guards, analytics, coaching -- all in one place.
-## Philosophy
+
+
+hookwise TUI — your Claude Code usage at a glance
-I built hookwise because I kept waking up to disasters.
-
-Not "my server is down" disasters — "my AI rewrote the build system while I was making coffee" disasters. Claude Code is extraordinary. It can refactor an entire module, set up infrastructure, write tests — all while you are reading the previous diff. That power is exactly why it needs guard rails.
-
-The existing hooks system is powerful but raw. You write bash scripts, scatter them across your config, and hope they cover the right events. There is no testing, no sharing, and no way to know if your guards actually work until something slips through.
+
-**hookwise is one YAML file that replaces all of that.** Declarative guards that read like firewall rules. Analytics that tell you where your time actually goes. Coaching that nudges you when you have been in "tooling mode" for 90 minutes and forgot your original goal.
+## Why hookwise?
-The design principle is simple: **your AI should never be the reason you cannot sleep.** Everything in hookwise serves that principle — guards protect, context enriches, side effects observe. If any part of hookwise itself errors, it fails open. hookwise must never be the thing that breaks your flow.
+Claude Code [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) are powerful but raw -- bash scripts, no testing, no sharing. **hookwise** is one YAML file: declarative guards, testable rules, shareable recipes. If hookwise errors, it fails open -- your AI keeps working.
-> *"Guard rails should be boring. The exciting part is what you build when you are not worried about what your AI is doing."*
+> *Guard rails should be boring. The exciting part is what you build when you are not worried about what your AI is doing.*
-## Quick Start
+### Without hookwise
```bash
-# Install globally
-npm install -g hookwise
-
-# Initialize in your project
-hookwise init --preset minimal
+# .claude/settings.json — one script per guard, scattered across your project
+"PreToolUse": [{ "command": "bash scripts/check-rm.sh" }]
-# Verify everything works
-hookwise doctor
+# scripts/check-rm.sh (repeat for every rule...)
+#!/bin/bash
+INPUT=$(cat)
+CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
+if echo "$CMD" | grep -q "rm -rf"; then
+ echo '{"decision":"block","reason":"dangerous"}'
+fi
```
-
-

-
-
-This creates `hookwise.yaml` in your project root and sets up the state directory. Then register hookwise in your Claude Code settings:
-
-```json
-{
- "hooks": {
- "PreToolUse": [{ "command": "hookwise dispatch PreToolUse" }],
- "PostToolUse": [{ "command": "hookwise dispatch PostToolUse" }],
- "SessionStart": [{ "command": "hookwise dispatch SessionStart" }],
- "SessionEnd": [{ "command": "hookwise dispatch SessionEnd" }],
- "Stop": [{ "command": "hookwise dispatch Stop" }],
- "Notification": [{ "command": "hookwise dispatch Notification" }],
- "SubagentStop": [{ "command": "hookwise dispatch SubagentStop" }]
- }
-}
-```
-
-## Configuration
-
-Everything lives in `hookwise.yaml`:
+### With hookwise
```yaml
-version: 1
-
-# Guard rules -- first match wins, like a firewall
+# hookwise.yaml — add a rule, remove a rule, done
guards:
- match: "Bash"
action: block
when: 'tool_input.command contains "rm -rf"'
reason: "Dangerous command blocked"
-
- - match: "Bash"
- action: confirm
- when: 'tool_input.command contains "--force"'
- reason: "Force flag requires confirmation"
-
- - match: "Read"
- action: warn
- when: 'tool_input.file_path ends_with ".env"'
- reason: "Accessing .env file -- may contain secrets"
-
- - match: "mcp__gmail__*"
- action: confirm
- reason: "Gmail tool requires confirmation"
-
-# Ambient coaching -- nudges, not interruptions
-coaching:
- metacognition:
- enabled: true
- interval_seconds: 300
- builder_trap:
- enabled: true
- thresholds:
- yellow: 30
- orange: 60
- red: 90
-
-# Session analytics -- SQLite, queryable
-analytics:
- enabled: true
-
-# Cost tracking -- no surprise bills
-cost_tracking:
- enabled: true
- daily_budget: 10
- enforcement: warn
-
-# Status line -- pick your segments
-status_line:
- enabled: true
- segments:
- - ai_ratio
- - session
- - builder_trap
- - cost
-
-# Include recipes for reusable patterns
-includes:
- - recipes/safety/block-dangerous-commands
- - recipes/behavioral/metacognition-prompts
```
-### Presets
-
-| Preset | What you get |
-|--------|-------------|
-| `minimal` | Guards only -- just the safety rails |
-| `coaching` | Guards + metacognition + builder's trap + status line |
-| `analytics` | Guards + SQLite session tracking |
-| `full` | Everything enabled |
-
-## Features
-
-### Guard Rails
-
-Declarative rules evaluated on every `PreToolUse` event. First matching rule wins (firewall semantics).
-
-| Operator | Example |
-|----------|---------|
-| `contains` | `tool_input.command contains "rm -rf"` |
-| `starts_with` | `tool_input.file_path starts_with "/etc"` |
-| `ends_with` | `tool_input.file_path ends_with ".env"` |
-| `equals` / `==` | `tool_name equals "Bash"` |
-| `matches` | `tool_input.command matches "git push.*--force"` |
-
-Three actions: **block** (reject the tool call), **confirm** (reserved, currently behaves as block), **warn** (log and continue).
-
-
-

-
-
-> **Note:** In v1.0, `confirm` behaves identically to `block`. Claude Code hooks do not support interactive confirmation dialogs. The `confirm` action is reserved for future use when Claude Code adds confirmation support.
-
-Glob patterns for tool names: `mcp__gmail__*` matches all Gmail MCP tools.
-
-### Builder's Trap Detection
-
-Monitors your tool usage patterns and categorizes them as coding, reviewing, or tooling. When you have been in "tooling mode" too long:
-
-- **30 min** -- Yellow: "Is this moving the needle?"
-- **60 min** -- Orange: "Time to refocus."
-- **90 min** -- Red: "Stop and ask: what was my original goal?"
-
-
-

-
-
-### Metacognition Coaching
-
-Periodic prompts that interrupt autopilot mode:
-
-- "Pause: Are you solving the right problem?"
-- "What would you explain to a junior dev about this change?"
-- "You just accepted a large AI change very quickly. Can you explain what it does?"
-
-
-

-
-
-### Communication Coach
-
-Grammar and communication analysis for interview prep and professional writing.
-
-### Session Analytics
-
-SQLite-backed tracking with `hookwise stats`:
-
-- Session duration and tool call counts
-- AI-vs-human authorship ratio with AI Confidence Score
-- Tool breakdown by category
-- Cost tracking with daily budget enforcement
-
-
-

-
+One file. Claude Code reads it, understands it, and can even help you write new rules. No bash scripts to debug.
-### Feed Platform
-
-A background daemon polls feed producers on configurable intervals and writes results to an atomic cache bus. Status line segments read from the cache using TTL-aware freshness checks (`isFresh()`). If a feed is unavailable or stale, its segments silently disappear (fail-open).
-
-5 built-in feed producers:
+## How It Compares
-| Producer | Default Interval | What it provides |
-|----------|-----------------|-----------------|
-| `pulse` | 30s | Session heartbeat and idle time detection |
-| `project` | 60s | Git repo name, branch, last commit age |
-| `calendar` | 300s | Current/next calendar event and free time |
-| `news` | 1800s | Hacker News top stories (rotates) |
-| `insights` | 120s | Claude Code usage metrics from `~/.claude/usage-data/` |
+| | hookwise | Raw hook scripts | Status line tools |
+|---|---------|-----------------|------------------|
+| Guard rails | Declarative YAML | Manual bash | No |
+| Testing | GuardTester + HookRunner | Manual | N/A |
+| Coaching | Metacognition prompts | No | No |
+| Analytics | SQLite, queryable | DIY | Display-only |
+| Configuration | One YAML file | Scattered scripts | JSON/TUI |
+| Recipes | 12 built-in, shareable | N/A | N/A |
+| Cost tracking | Budgets + alerts | DIY | Current session only |
-Configure feeds in `hookwise.yaml`:
+## Quick Start
-```yaml
-feeds:
- pulse:
- enabled: true
- interval_seconds: 30
- project:
- enabled: true
- interval_seconds: 60
- insights:
- enabled: true
- interval_seconds: 120
- staleness_days: 30
- usage_data_path: "~/.claude/usage-data"
+```bash
+npm install -g hookwise
+hookwise init --preset minimal
+hookwise doctor
```
-The daemon starts automatically with `hookwise dispatch` and shuts down after 120 minutes of inactivity.
-
-#### Insights Producer
-
-The insights producer reads Claude Code's usage data from `~/.claude/usage-data/` (session-meta and facets JSON files), aggregates metrics within a configurable staleness window, and writes them to the cache bus under the `insights` key.
-
-**Aggregated metrics:**
-- `total_sessions` -- count of sessions within the staleness window
-- `total_messages` -- sum of user messages across sessions
-- `total_lines_added` -- sum of lines added across sessions
-- `avg_duration_minutes` -- mean session duration
-- `top_tools` -- top 5 tools by total call count
-- `friction_counts` -- aggregated friction categories (e.g., `{wrong_approach: 32, misunderstood_request: 14}`)
-- `friction_total` -- sum of all friction instances
-- `peak_hour` -- hour (0-23) with the most messages
-- `days_active` -- unique calendar dates with at least one session
-- `recent_session` -- most recent session summary (id, duration, lines added, friction count, outcome, tool errors)
-
-**Self-cleaning:** Only sessions within the staleness window (default: 30 days) are included. Facets without a matching valid session are excluded. No manual cleanup needed.
-
-**Configuration:**
-
-| Field | Type | Default | Description |
-|-------|------|---------|-------------|
-| `enabled` | boolean | `true` | Enable/disable the insights producer |
-| `interval_seconds` | number | `120` | Polling interval in seconds |
-| `staleness_days` | number | `30` | Only include sessions newer than this many days |
-| `usage_data_path` | string | `~/.claude/usage-data` | Path to Claude Code usage data directory |
-
-**Fail-open behavior:** Missing directory, malformed JSON files, and permission errors all result in graceful degradation -- the producer returns null and segments disappear.
-
-### Composable Status Line
-
-16 segments you can mix and match:
-
-

+
-**Core segments:**
-
-| Segment | Shows |
-|---------|-------|
-| `clock` | Current time |
-| `session` | Duration + tool count |
-| `ai_ratio` | AI-generated code percentage |
-| `cost` | Session cost estimate |
-| `builder_trap` | Alert level + nudge message |
-| `mantra` | Rotating motivational prompt |
-| `practice` | Daily practice rep counter |
-
-**Two-tier segments:**
-
-| Segment | Shows |
-|---------|-------|
-| `context_bar` | Context window usage bar |
-| `mode_badge` | Current mode (coding/tooling/etc.) |
-| `duration` | Total session duration |
-| `practice_breadcrumb` | Time since last practice |
-
-**Feed platform segments:**
-
-| Segment | Shows |
-|---------|-------|
-| `pulse` | Session heartbeat |
-| `project` | Repo + branch + last commit |
-| `calendar` | Current/next event |
-| `news` | Hacker News headlines |
-
-**Insights segments:**
-
-| Segment | Shows |
-|---------|-------|
-| `insights_friction` | Friction health: warns on recent friction, shows clean status otherwise |
-| `insights_pace` | Productivity: messages/day, total lines, session count |
-| `insights_trend` | Patterns: top tools and peak coding hour |
-
-### Interactive TUI
-
-Full-screen terminal UI built with Python Textual — 8 tabs:
-
-| Key | Tab | Description |
-|-----|-----|-------------|
-| `1` | Dashboard | Feature overview with enabled/disabled status |
-| `2` | Guards | Guard rules table with action descriptions |
-| `3` | Coaching | Coaching features with user-friendly explanations |
-| `4` | Analytics | Sparkline trends, AI authorship ratio, tool breakdown |
-| `5` | Feeds | Live feed dashboard with auto-refresh and health indicators |
-| `6` | Insights | Claude Code usage metrics, trends, and daily AI summary |
-| `7` | Recipes | Recipe browser grouped by category |
-| `8` | Status | Status line preview and segment configurator |
-
-Press `q` to exit the TUI. Install: `cd tui && pip install -e .`
+Then [register hookwise in `.claude/settings.json`](docs/guide/getting-started.md) — one dispatcher handles all 13 hook events.
-## Recipes
+## What You Get
-12 built-in recipes -- pre-configured patterns for common needs:
-
-| Recipe | Category | What it does |
-|--------|----------|-------------|
-| `block-dangerous-commands` | safety | Blocks `rm -rf /`, `rm -rf ~`, force pushes |
-| `secret-scanning` | safety | Detects and masks secrets in tool output |
-| `ai-dependency-tracker` | behavioral | Tracks AI usage patterns over time |
-| `metacognition-prompts` | behavioral | Periodic "step back and think" nudges |
-| `builder-trap-detection` | behavioral | Detects over-engineering and scope creep |
-| `cost-tracking` | compliance | API costs against daily budgets |
-| `transcript-backup` | productivity | Saves session transcripts for review |
-| `context-window-monitor` | productivity | Monitors context window usage |
-| `friction-alert` | productivity | Warns when friction patterns exceed threshold in recent sessions |
-| `streak-tracker` | gamification | Tracks coding streaks and consistency |
-| `commit-without-tests` | quality | Warns when committing without running tests |
-| `file-creation-police` | quality | Enforces file creation policies |
-
-Include a recipe in your config:
+**[Guard Rails](docs/features/guards.md)** -- Declarative rules with firewall semantics. `block` or `warn` with glob patterns and operators like `contains`, `matches`, `starts_with`.
-```yaml
-includes:
- - recipes/safety/block-dangerous-commands
- - recipes/behavioral/metacognition-prompts
-```
+
-Or create your own -- see [Creating a Recipe](docs/creating-a-recipe.md).
+**[Coaching](docs/features/coaching.md)** -- Periodic **metacognition prompts** break autopilot mode: *"Are you solving the right problem, or the most interesting one?"*
-## CLI Commands
+**[Status Line](docs/features/status-line.md)** -- 21 composable segments powered by a [background daemon](docs/features/feeds.md) with 8 built-in feed producers. Mix `session`, `cost`, `project`, `calendar`, `news`, `insights`, and more.
-```
-hookwise init [--preset minimal|coaching|analytics|full]
- Generate hookwise.yaml and state directory
+
-hookwise doctor Health check: config, state dir, handlers
-```
+**[Analytics](docs/features/analytics.md)** -- SQLite-backed session tracking: tool calls, duration, cost, daily budgets.
-
-

-
+
-```
-hookwise status Show current configuration summary
+**[Interactive TUI](docs/cli.md)** -- Full-screen dashboard with 8 tabs: guards, coaching, analytics, feeds, insights, recipes, status. Run `hookwise tui`.
-hookwise stats Session analytics: tool calls, authorship, cost
+
-hookwise test Run guard rule tests against scenarios
+## Configuration
-hookwise tui Launch the interactive TUI for config management
+Everything lives in `hookwise.yaml`. Four presets: `minimal`, `coaching`, `analytics`, `full`. [Full reference →](docs/guide/getting-started.md)
-hookwise migrate Migrate from Python hookwise (v0.1.0) to TypeScript
+```yaml
+version: 1
+guards:
+ - match: "Bash"
+ action: block
+ when: 'tool_input.command contains "rm -rf"'
+ reason: "Dangerous command blocked"
+coaching:
+ metacognition: { enabled: true, interval_seconds: 300 }
+analytics: { enabled: true }
+status_line: { enabled: true, segments: [session, cost, project, calendar] }
```
-## Testing Your Guards
-
-hookwise includes testing utilities so you can validate guards in CI:
+## Testing
```typescript
import { GuardTester } from "hookwise/testing";
-
const tester = new GuardTester("hookwise.yaml");
-
-// Test blocking
-const blocked = tester.evaluate("Bash", { command: "rm -rf /" });
-expect(blocked.action).toBe("block");
-
-// Test allowing
-const allowed = tester.evaluate("Bash", { command: "ls -la" });
-expect(allowed.action).toBe("allow");
-```
-
-Three testing utilities are exported:
-
-- **`GuardTester`** -- In-process guard rule evaluation (fast, no subprocess)
-- **`HookRunner`** -- Subprocess-based hook execution (tests the real dispatch path)
-- **`HookResult`** -- Assertion helpers (`assertBlocked()`, `assertAllowed()`, `assertWarns()`)
-
-## Architecture
-
-hookwise registers one dispatcher for all 13 Claude Code hook events:
-
-> **Diagram source:** [three-phase-engine.excalidraw](docs/assets/three-phase-engine.excalidraw) — open in [Excalidraw](https://excalidraw.com) for an editable hand-drawn version.
-
-```mermaid
-graph TD
- A[13 Hook Events] --> B[Dispatcher
hookwise.yaml config
fail-open: any error → exit 0]
- B --> C[Phase 1: Guards
block / confirm / warn]
- B --> D[Phase 2: Context
enrich tool calls]
- B --> E[Phase 3: Side Effects
log, coach, analytics]
- C -->|BLOCKED| F[Exit with deny JSON]
-
- style C fill:#FFE3E3,stroke:#C92A2A,stroke-width:2px
- style D fill:#D3F9D8,stroke:#2B8A3E,stroke-width:2px
- style E fill:#FFF0DB,stroke:#D4853B,stroke-width:2px
- style F fill:#FFE3E3,stroke:#C92A2A,stroke-width:2px
-```
-
-**Three-phase execution:**
-
-1. **Guards** -- Decide if the tool call should proceed. First block wins (short-circuit). If any guard blocks, phases 2 and 3 are skipped.
-2. **Context Injection** -- Enrich the tool call with additional context (greeting, metacognition prompts). Multiple context handlers merge their output.
-3. **Side Effects** -- Non-blocking operations that observe and respond (analytics, coaching state, sounds, transcript backup).
-
-**Fail-open guarantee:** Any unhandled exception anywhere in the dispatch pipeline results in `exit 0`. hookwise must never accidentally block a tool call due to internal errors.
-
-### Feed Platform Architecture
-
-> **Diagram source:** [feed-platform.excalidraw](docs/assets/feed-platform.excalidraw) — open in [Excalidraw](https://excalidraw.com) for an editable hand-drawn version.
-
-```mermaid
-graph LR
- subgraph Sources[Data Sources]
- S1[Git Repo]
- S2[HN API]
- S3[Calendar]
- S4[Heartbeat]
- S5[Usage Data]
- end
-
- subgraph Daemon[Background Daemon]
- D1[Feed Registry
5 producers]
- D2[pulse 30s]
- D3[project 60s]
- D4[calendar 5m]
- D5[news 30m]
- D6[insights 2m]
- end
-
- Sources --> D1
- D1 --> CB[Cache Bus
Atomic JSON
Per-key TTL
isFresh check]
- CB --> SL[Status Line
19 segments]
-
- style Sources fill:#D0EBFF,stroke:#1971C2,stroke-width:2px
- style Daemon fill:#D3F9D8,stroke:#2B8A3E,stroke-width:2px
- style CB fill:#FFF0DB,stroke:#D4853B,stroke-width:2px
- style SL fill:#FFE3E3,stroke:#C92A2A,stroke-width:2px
+expect(tester.evaluate("Bash", { command: "rm -rf /" }).action).toBe("block");
```
-**Config resolution:**
-1. Global config (`~/.hookwise/config.yaml`)
-2. Project config (`./hookwise.yaml`)
-3. Deep merge: project values override global
-4. Include resolution (recipes)
-5. v0.1.0 backward compatibility transform
-6. snake_case to camelCase conversion
-7. Environment variable interpolation (`${VAR_NAME}`)
-8. Defaults fill missing fields
-
-## How It Compares
-
-| | hookwise | Status line tools | Raw hook scripts |
-|---|---------|------------------|-----------------|
-| Guard rails | Declarative YAML | No | Manual bash |
-| Session analytics | SQLite, queryable | Display-only | DIY |
-| Coaching | Built-in | No | No |
-| Configuration | One YAML file | JSON/TUI | Scattered scripts |
-| Testing | GuardTester, HookRunner | N/A | Manual |
-| Recipes | 12 built-in | N/A | N/A |
-| Cost tracking | Budgets + alerts | Current session only | DIY |
-| Interactive TUI | 8 tabs | N/A | N/A |
-
-## Development
+Also exports `HookRunner` and `HookResult`. [Details →](docs/cli.md)
-```bash
-git clone https://github.com/vishnujayvel/hookwise.git
-cd hookwise
-npm install
-npm test # 1363 tests via vitest
-npm run build # tsup build
-npm run typecheck # tsc --noEmit
-```
+## Recipes
-### Project Structure
-
-```
-src/
- core/ # Dispatcher, config, guards, analytics, coaching
- analytics/ # SQLite analytics engine
- coaching/ # Metacognition, builder's trap, communication
- feeds/ # Feed platform: producers, cache bus, registry
- status-line/ # Composable status segments
- cli/ # CLI commands (init, doctor, status, stats, test, migrate)
- testing/ # HookRunner, HookResult, GuardTester
-
-tui/ # Interactive TUI (Python Textual)
- hookwise_tui/ # App, tabs, widgets, data readers
-
-tests/ # 1363+ tests across 61 test files
- core/ # Unit tests for each module
- integration/ # End-to-end dispatch flow tests
- performance/ # Benchmarks and import boundary tests
- cli/ # CLI command tests
-
-recipes/ # 12 built-in recipes
-examples/ # 4 example configs (minimal, coaching, analytics, full)
-```
+12 built-in -- [see all](recipes/) or [create your own](docs/guide/creating-a-recipe.md): `block-dangerous-commands`, `metacognition-prompts`, `commit-without-tests`, and more.
## Documentation
-- [Getting Started](docs/getting-started.md)
-- [Hook Events Reference](docs/hook-events-reference.md)
-- [Creating a Recipe](docs/creating-a-recipe.md)
-- [TUI Guide](docs/tui-guide.md)
-- [Migration from Python](docs/migration-from-python.md)
-- [Contributing](CONTRIBUTING.md)
-
-## License
+| Guide | Reference |
+|-------|-----------|
+| [Getting Started](docs/guide/getting-started.md) | [Guards](docs/features/guards.md) |
+| [Creating a Recipe](docs/guide/creating-a-recipe.md) | [Coaching](docs/features/coaching.md) |
+| [Architecture](docs/architecture.md) | [Feeds](docs/features/feeds.md) |
+| [Philosophy](docs/philosophy.md) | [Status Line](docs/features/status-line.md) |
+| [CLI Reference](docs/cli.md) | [Analytics](docs/features/analytics.md) |
-[MIT](LICENSE)
+## Contributing
-## Author
+`git clone`, `npm install`, `npm test` (1,487 tests), `npm run build`. See [CONTRIBUTING.md](CONTRIBUTING.md).
-Built by [Vishnu](https://github.com/vishnujayvel). Born from watching Claude Code do amazing things -- and occasionally terrifying things.
+[MIT](LICENSE) -- Built by [Vishnu](https://github.com/vishnujayvel). *Born from watching Claude Code do amazing things -- and occasionally terrifying things.*
diff --git a/package.json b/package.json
index 9075a2d..53aa53c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hookwise",
- "version": "1.2.0",
+ "version": "1.3.0",
"description": "Config-driven hook framework for Claude Code with guards, analytics, coaching, and interactive TUI",
"type": "module",
"bin": {
@@ -13,7 +13,8 @@
"files": [
"dist/",
"recipes/",
- "examples/"
+ "examples/",
+ "scripts/"
],
"engines": {
"node": ">=20.0.0"
diff --git a/scripts/calendar-feed.py b/scripts/calendar-feed.py
index 1ad4d91..dffe875 100755
--- a/scripts/calendar-feed.py
+++ b/scripts/calendar-feed.py
@@ -7,15 +7,19 @@
Usage:
python3 calendar-feed.py --lookahead 60 --credentials /path/to/credentials.json
+ python3 calendar-feed.py --setup --credentials /path/to/credentials.json
Setup:
1. Create OAuth 2.0 credentials in Google Cloud Console
2. Download the credentials JSON file
3. Set the path in hookwise.yaml under feeds.calendar.credentialsPath
4. Install dependencies: pip install google-auth google-auth-oauthlib google-api-python-client
+ 5. Or use: hookwise setup calendar (handles steps 2-4 automatically)
The first run will open a browser for OAuth consent. Subsequent runs use
the cached token at ~/.hookwise/calendar-token.json.
+
+The --setup flag runs only the OAuth flow and validates the token, then exits.
"""
import argparse
@@ -41,6 +45,11 @@ def main():
required=True,
help="Path to Google OAuth credentials JSON file",
)
+ parser.add_argument(
+ "--setup",
+ action="store_true",
+ help="Only run the OAuth flow and validate the token, then exit",
+ )
args = parser.parse_args()
if not os.path.exists(args.credentials):
@@ -84,6 +93,18 @@ def main():
with open(TOKEN_PATH, "w") as token_file:
token_file.write(creds.to_json())
+ # --setup mode: validate token and exit without fetching events
+ if args.setup:
+ if os.path.exists(TOKEN_PATH):
+ print("Calendar OAuth setup complete. Token saved to " + TOKEN_PATH)
+ sys.exit(0)
+ else:
+ print(
+ "Setup failed: token file was not created at " + TOKEN_PATH,
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
service = build("calendar", "v3", credentials=creds)
now = datetime.now(timezone.utc)
diff --git a/src/cli/commands/doctor.tsx b/src/cli/commands/doctor.tsx
index 5b29ddd..fa02341 100644
--- a/src/cli/commands/doctor.tsx
+++ b/src/cli/commands/doctor.tsx
@@ -143,6 +143,9 @@ function runChecks(dir: string): Check[] {
loadedConfig.feeds.calendar.enabled ||
loadedConfig.feeds.news.enabled ||
loadedConfig.feeds.insights.enabled ||
+ loadedConfig.feeds.practice.enabled ||
+ loadedConfig.feeds.weather.enabled ||
+ loadedConfig.feeds.memories.enabled ||
loadedConfig.feeds.custom.some((f) => f.enabled);
if (anyFeedEnabled) {
diff --git a/src/cli/commands/init.tsx b/src/cli/commands/init.tsx
index 21c34e7..3d613df 100644
--- a/src/cli/commands/init.tsx
+++ b/src/cli/commands/init.tsx
@@ -63,8 +63,10 @@ function applyPreset(config: HooksConfig, preset: Preset): HooksConfig {
calendar: { ...result.feeds.calendar, enabled: true },
news: { ...result.feeds.news, enabled: true },
insights: { ...result.feeds.insights, enabled: true },
+ practice: { ...result.feeds.practice, enabled: true },
};
result.daemon = { ...result.daemon, autoStart: true };
+ result.tui = { ...result.tui, autoLaunch: true };
break;
}
diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts
index f61bddc..2352a3d 100644
--- a/src/cli/commands/setup.ts
+++ b/src/cli/commands/setup.ts
@@ -4,13 +4,26 @@
* Uses plain stdout (not React/Ink) so it works in scripts and pipelines.
* Called directly from runCli() in app.tsx, bypassing the render() path.
*
- * Currently supports: calendar (Google Calendar OAuth - stub).
+ * Currently supports: calendar (Google Calendar OAuth).
+ *
+ * Flow for `hookwise setup calendar`:
+ * 1. Check if token already exists (already configured)
+ * 2. Check for env vars HOOKWISE_GOOGLE_CLIENT_ID / HOOKWISE_GOOGLE_CLIENT_SECRET
+ * 3. Write credentials JSON from env vars
+ * 4. Run Python script in --setup mode to trigger OAuth consent flow
+ * 5. Verify token file was created
*
* Requirements: FR-10.1, FR-10.2, FR-10.3, FR-10.4, FR-10.5, NFR-3
*/
-import { DEFAULT_CALENDAR_CREDENTIALS_PATH } from "../../core/constants.js";
-import { existsSync } from "node:fs";
+import {
+ DEFAULT_CALENDAR_CREDENTIALS_PATH,
+ DEFAULT_CALENDAR_TOKEN_PATH,
+} from "../../core/constants.js";
+import { existsSync, writeFileSync, mkdirSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
/**
* Run the setup CLI command.
@@ -32,25 +45,94 @@ export async function runSetupCommand(target: string): Promise {
}
}
+/**
+ * Resolve the path to scripts/calendar-feed.py relative to this module.
+ */
+function getScriptPath(): string {
+ const thisFile = fileURLToPath(import.meta.url);
+ // Navigate from dist/cli/commands/ up to package root
+ const projectRoot = resolve(dirname(thisFile), "..", "..", "..");
+ return resolve(projectRoot, "scripts", "calendar-feed.py");
+}
+
+/**
+ * Write a Google OAuth credentials JSON file from client ID and secret.
+ * Creates parent directories if needed.
+ */
+function writeCredentialsFile(
+ path: string,
+ clientId: string,
+ clientSecret: string,
+): void {
+ const credentials = {
+ installed: {
+ client_id: clientId,
+ client_secret: clientSecret,
+ redirect_uris: ["http://localhost"],
+ auth_uri: "https://accounts.google.com/o/oauth2/auth",
+ token_uri: "https://oauth2.googleapis.com/token",
+ },
+ };
+
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
+ writeFileSync(path, JSON.stringify(credentials, null, 2), {
+ encoding: "utf-8",
+ mode: 0o600,
+ });
+}
+
+/**
+ * Run the Python calendar-feed.py script in --setup mode.
+ * Returns true on success (exit 0), false on failure.
+ */
+function runOAuthSetup(credentialsPath: string): boolean {
+ const scriptPath = getScriptPath();
+
+ try {
+ const stdout = execFileSync(
+ "python3",
+ [scriptPath, "--setup", "--credentials", credentialsPath],
+ {
+ encoding: "utf-8",
+ stdio: ["pipe", "pipe", "pipe"],
+ timeout: 120_000, // 2 minutes for user to complete OAuth in browser
+ },
+ );
+ if (stdout.trim()) {
+ console.log(stdout.trim());
+ }
+ return true;
+ } catch (error: unknown) {
+ const err = error as { stderr?: string; message?: string };
+ if (err.stderr) {
+ console.error(err.stderr.trim());
+ } else if (err.message) {
+ console.error(`OAuth setup failed: ${err.message}`);
+ }
+ return false;
+ }
+}
+
/**
* Set up Google Calendar integration.
*
- * This is a stub implementation. The full OAuth flow requires
- * Google API credentials which will be implemented in a future release.
+ * 1. Check for existing token (already configured)
+ * 2. Check for env vars with client credentials
+ * 3. Write credentials JSON from env vars
+ * 4. Run Python OAuth flow
+ * 5. Verify token was created
*/
async function setupCalendar(): Promise {
console.log("Setting up Google Calendar integration...\n");
- // Step 1: Check for existing credentials
- if (existsSync(DEFAULT_CALENDAR_CREDENTIALS_PATH)) {
- console.log(`Existing credentials found at ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);
+ // Step 1: Check for existing token — means OAuth already completed
+ if (existsSync(DEFAULT_CALENDAR_TOKEN_PATH)) {
console.log("Calendar integration is already configured.");
+ console.log(`Token: ${DEFAULT_CALENDAR_TOKEN_PATH}`);
return;
}
- console.log("No existing credentials found.\n");
-
- // Step 2: Check for Google client ID
+ // Step 2: Check for Google client credentials in env vars
const clientId = process.env.HOOKWISE_GOOGLE_CLIENT_ID;
const clientSecret = process.env.HOOKWISE_GOOGLE_CLIENT_SECRET;
@@ -65,12 +147,53 @@ async function setupCalendar(): Promise {
console.log(" export HOOKWISE_GOOGLE_CLIENT_ID=");
console.log(" export HOOKWISE_GOOGLE_CLIENT_SECRET=\n");
console.log(" 6. Run 'hookwise setup calendar' again");
+ // Fail gracefully — don't crash, just set non-zero exit
process.exitCode = 1;
return;
}
- // Step 3: Client ID present but OAuth not implemented yet
+ // Step 3: Write credentials JSON file from env vars
console.log("Google API credentials detected.");
- console.log("OAuth flow not yet implemented. Coming in a future release.");
- console.log(`\nCredentials will be stored at: ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);
+
+ // Only write the credentials file if it doesn't already exist
+ if (!existsSync(DEFAULT_CALENDAR_CREDENTIALS_PATH)) {
+ try {
+ writeCredentialsFile(DEFAULT_CALENDAR_CREDENTIALS_PATH, clientId, clientSecret);
+ console.log(`Credentials written to ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : String(error);
+ console.error(`Failed to write credentials file: ${msg}`);
+ process.exitCode = 2;
+ return;
+ }
+ } else {
+ console.log(`Using existing credentials at ${DEFAULT_CALENDAR_CREDENTIALS_PATH}`);
+ }
+
+ // Step 4: Run Python OAuth flow
+ console.log("\nStarting OAuth flow — a browser window will open...\n");
+ const success = runOAuthSetup(DEFAULT_CALENDAR_CREDENTIALS_PATH);
+
+ if (!success) {
+ console.error("\nOAuth setup did not complete successfully.");
+ console.log("You can retry with: hookwise setup calendar");
+ process.exitCode = 2;
+ return;
+ }
+
+ // Step 5: Verify token was created
+ if (existsSync(DEFAULT_CALENDAR_TOKEN_PATH)) {
+ console.log("\nCalendar setup complete! You can now enable the calendar feed in hookwise.yaml:");
+ console.log("\n feeds:");
+ console.log(" calendar:");
+ console.log(" enabled: true");
+ } else {
+ console.error("\nSetup appeared to succeed but token file was not found.");
+ console.error(`Expected at: ${DEFAULT_CALENDAR_TOKEN_PATH}`);
+ console.log("You can retry with: hookwise setup calendar");
+ process.exitCode = 2;
+ }
}
+
+// Exported for testing
+export { writeCredentialsFile, runOAuthSetup, getScriptPath };
diff --git a/src/cli/commands/status.tsx b/src/cli/commands/status.tsx
index 3f3a90c..839cfe6 100644
--- a/src/cli/commands/status.tsx
+++ b/src/cli/commands/status.tsx
@@ -61,6 +61,9 @@ function getStatusInfo(config: HooksConfig): StatusInfo {
{ name: "Calendar", enabled: config.feeds.calendar.enabled },
{ name: "News", enabled: config.feeds.news.enabled },
{ name: "Insights", enabled: config.feeds.insights.enabled },
+ { name: "Practice", enabled: config.feeds.practice.enabled },
+ { name: "Weather", enabled: config.feeds.weather.enabled },
+ { name: "Memories", enabled: config.feeds.memories.enabled },
],
};
}
diff --git a/src/core/config.ts b/src/core/config.ts
index eefa925..f4ff2ea 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -24,10 +24,13 @@ import yaml from "js-yaml";
import {
DEFAULT_STATE_DIR,
DEFAULT_CACHE_PATH,
+ DEFAULT_DB_PATH,
DEFAULT_HANDLER_TIMEOUT,
DEFAULT_STATUS_DELIMITER,
DEFAULT_TRANSCRIPT_DIR,
DEFAULT_DAEMON_LOG_PATH,
+ DEFAULT_CALENDAR_CREDENTIALS_PATH,
+ DEFAULT_CALENDAR_TOKEN_PATH,
PROJECT_CONFIG_FILE,
GLOBAL_CONFIG_PATH,
} from "./constants.js";
@@ -234,6 +237,8 @@ export function getDefaultConfig(): HooksConfig {
intervalSeconds: 300,
lookaheadMinutes: 120,
calendars: ["primary"],
+ credentialsPath: DEFAULT_CALENDAR_CREDENTIALS_PATH,
+ tokenPath: DEFAULT_CALENDAR_TOKEN_PATH,
},
news: {
enabled: false,
@@ -249,6 +254,23 @@ export function getDefaultConfig(): HooksConfig {
stalenessDays: 30,
usageDataPath: "~/.claude/usage-data",
},
+ practice: {
+ enabled: true,
+ intervalSeconds: 120,
+ dbPath: "~/.practice-tracker/practice-tracker.db",
+ },
+ weather: {
+ enabled: false,
+ intervalSeconds: 600,
+ latitude: 37.7749,
+ longitude: -122.4194,
+ temperatureUnit: "fahrenheit",
+ },
+ memories: {
+ enabled: false,
+ intervalSeconds: 3600,
+ dbPath: DEFAULT_DB_PATH,
+ },
custom: [],
},
daemon: {
@@ -256,6 +278,10 @@ export function getDefaultConfig(): HooksConfig {
inactivityTimeoutMinutes: 120,
logFile: DEFAULT_DAEMON_LOG_PATH,
},
+ tui: {
+ autoLaunch: false,
+ launchMethod: "newWindow",
+ },
};
}
@@ -467,6 +493,7 @@ const KNOWN_SECTIONS = new Set([
"includes",
"feeds",
"daemon",
+ "tui",
]);
/** Known top-level sections in snake_case (for YAML) */
@@ -485,6 +512,7 @@ const KNOWN_SECTIONS_SNAKE = new Set([
"includes",
"feeds",
"daemon",
+ "tui",
]);
/**
@@ -757,6 +785,85 @@ export function validateConfig(raw: Record): ValidationResult {
}
}
+ // Validate feeds.practice
+ if (feeds.practice !== undefined && typeof feeds.practice === "object" && feeds.practice !== null) {
+ const practice = feeds.practice as Record;
+ const interval = (practice.interval_seconds ?? practice.intervalSeconds) as number | undefined;
+ if (interval !== undefined && (typeof interval !== "number" || interval <= 0)) {
+ errors.push({
+ path: "feeds.practice.interval_seconds",
+ message: "interval_seconds must be a positive number",
+ suggestion: "Set interval_seconds: 120",
+ });
+ }
+ const dbPath = (practice.db_path ?? practice.dbPath) as string | undefined;
+ if (dbPath !== undefined && (typeof dbPath !== "string" || dbPath.trim() === "")) {
+ errors.push({
+ path: "feeds.practice.db_path",
+ message: "db_path must be a non-empty string",
+ suggestion: "Set db_path: '~/.practice-tracker/practice-tracker.db'",
+ });
+ }
+ }
+
+ // Validate feeds.weather
+ if (feeds.weather !== undefined && typeof feeds.weather === "object" && feeds.weather !== null) {
+ const weather = feeds.weather as Record;
+ const interval = (weather.interval_seconds ?? weather.intervalSeconds) as number | undefined;
+ if (interval !== undefined && (typeof interval !== "number" || interval <= 0)) {
+ errors.push({
+ path: "feeds.weather.interval_seconds",
+ message: "interval_seconds must be a positive number",
+ suggestion: "Set interval_seconds: 600",
+ });
+ }
+ const lat = weather.latitude as number | undefined;
+ if (lat !== undefined && (typeof lat !== "number" || lat < -90 || lat > 90)) {
+ errors.push({
+ path: "feeds.weather.latitude",
+ message: "latitude must be a number between -90 and 90",
+ suggestion: "Set latitude: 37.7749",
+ });
+ }
+ const lon = weather.longitude as number | undefined;
+ if (lon !== undefined && (typeof lon !== "number" || lon < -180 || lon > 180)) {
+ errors.push({
+ path: "feeds.weather.longitude",
+ message: "longitude must be a number between -180 and 180",
+ suggestion: "Set longitude: -122.4194",
+ });
+ }
+ const tempUnit = (weather.temperature_unit ?? weather.temperatureUnit) as string | undefined;
+ if (tempUnit !== undefined && tempUnit !== "fahrenheit" && tempUnit !== "celsius") {
+ errors.push({
+ path: "feeds.weather.temperature_unit",
+ message: `Invalid temperature_unit: "${tempUnit}"`,
+ suggestion: "Use one of: fahrenheit, celsius",
+ });
+ }
+ }
+
+ // Validate feeds.memories
+ if (feeds.memories !== undefined && typeof feeds.memories === "object" && feeds.memories !== null) {
+ const memories = feeds.memories as Record;
+ const interval = (memories.interval_seconds ?? memories.intervalSeconds) as number | undefined;
+ if (interval !== undefined && (typeof interval !== "number" || interval <= 0)) {
+ errors.push({
+ path: "feeds.memories.interval_seconds",
+ message: "interval_seconds must be a positive number",
+ suggestion: "Set interval_seconds: 3600",
+ });
+ }
+ const dbPath = (memories.db_path ?? memories.dbPath) as string | undefined;
+ if (dbPath !== undefined && (typeof dbPath !== "string" || dbPath.trim() === "")) {
+ errors.push({
+ path: "feeds.memories.db_path",
+ message: "db_path must be a non-empty string",
+ suggestion: "Set db_path to a valid SQLite database path",
+ });
+ }
+ }
+
// Validate feeds.custom
if (feeds.custom !== undefined) {
if (!Array.isArray(feeds.custom)) {
@@ -812,6 +919,27 @@ export function validateConfig(raw: Record): ValidationResult {
}
}
+ // Validate tui
+ if (raw.tui !== undefined && typeof raw.tui === "object" && raw.tui !== null) {
+ const tui = raw.tui as Record;
+ const launchMethod = (tui.launch_method ?? tui.launchMethod) as string | undefined;
+ if (launchMethod !== undefined && launchMethod !== "newWindow" && launchMethod !== "background") {
+ errors.push({
+ path: "tui.launch_method",
+ message: `Invalid launch method: "${launchMethod}"`,
+ suggestion: "Use one of: newWindow, background",
+ });
+ }
+ const autoLaunch = (tui.auto_launch ?? tui.autoLaunch) as unknown;
+ if (autoLaunch !== undefined && typeof autoLaunch !== "boolean") {
+ errors.push({
+ path: "tui.auto_launch",
+ message: `Invalid auto_launch: expected boolean`,
+ suggestion: "Use true or false",
+ });
+ }
+ }
+
return {
valid: errors.length === 0,
errors,
diff --git a/src/core/constants.ts b/src/core/constants.ts
index 498f986..6eb53ba 100644
--- a/src/core/constants.ts
+++ b/src/core/constants.ts
@@ -57,5 +57,8 @@ export const DEFAULT_DAEMON_LOG_PATH = join(DEFAULT_STATE_DIR, "daemon.log");
/** Default calendar credentials path: ~/.hookwise/calendar-credentials.json */
export const DEFAULT_CALENDAR_CREDENTIALS_PATH = join(DEFAULT_STATE_DIR, "calendar-credentials.json");
+/** Default calendar token path: ~/.hookwise/calendar-token.json */
+export const DEFAULT_CALENDAR_TOKEN_PATH = join(DEFAULT_STATE_DIR, "calendar-token.json");
+
/** Default feed timeout in seconds */
export const DEFAULT_FEED_TIMEOUT = 10; // seconds
diff --git a/src/core/dispatcher.ts b/src/core/dispatcher.ts
index d998576..7f60a16 100644
--- a/src/core/dispatcher.ts
+++ b/src/core/dispatcher.ts
@@ -17,6 +17,7 @@ import type {
HandlerResult,
HooksConfig,
ResolvedHandler,
+ AnalyticsConfig,
} from "./types.js";
import { isEventType, isHookPayload } from "./types.js";
import { safeDispatch, logError, logDebug } from "./errors.js";
@@ -24,6 +25,114 @@ import { loadConfig, getHandlersForEvent, getDefaultConfig } from "./config.js";
import { evaluate } from "./guards.js";
import { mergeKey } from "./feeds/cache-bus.js";
import { isRunning, startDaemon } from "./feeds/daemon-manager.js";
+import { launchTui } from "./tui-launcher.js";
+import { AnalyticsDB } from "./analytics/db.js";
+import { AnalyticsEngine } from "./analytics/session.js";
+
+// --- Analytics Engine (lazy singleton) ---
+
+/**
+ * Cached analytics engine instance, keyed by dbPath.
+ * Re-created if the dbPath changes (e.g., between test runs).
+ */
+let cachedEngine: AnalyticsEngine | null = null;
+let cachedDbPath: string | null = null;
+
+/**
+ * Get or create the analytics engine for the given config.
+ * Returns null if analytics is disabled.
+ */
+function getAnalyticsEngine(analyticsConfig: AnalyticsConfig): AnalyticsEngine | null {
+ if (!analyticsConfig.enabled) {
+ if (cachedEngine) {
+ try { cachedEngine.getDB().close(); } catch { /* best-effort */ }
+ cachedEngine = null;
+ cachedDbPath = null;
+ }
+ return null;
+ }
+
+ // Normalize undefined to null so the cache comparison works correctly
+ const dbPath = analyticsConfig.dbPath ?? null;
+
+ // Re-use cached engine if dbPath matches
+ if (cachedEngine && cachedDbPath === dbPath) {
+ return cachedEngine;
+ }
+
+ // Close old DB handle before rotating (avoid leaking SQLite connections)
+ if (cachedEngine) {
+ try { cachedEngine.getDB().close(); } catch { /* best-effort cleanup */ }
+ }
+
+ // Create new engine
+ const db = new AnalyticsDB(dbPath ?? undefined);
+ cachedEngine = new AnalyticsEngine(db);
+ cachedDbPath = dbPath;
+ return cachedEngine;
+}
+
+/**
+ * Record analytics events based on the dispatch event type.
+ *
+ * Runs in Phase 3 (side effects). Wrapped in try/catch for fail-open
+ * behavior: analytics errors must NEVER affect the dispatch exit code (ARCH-3).
+ *
+ * - SessionStart: creates a session row
+ * - SessionEnd: updates the session row with end timestamp and summary
+ * - PostToolUse: records a tool use event
+ */
+function executeAnalytics(
+ engine: AnalyticsEngine,
+ eventType: EventType,
+ payload: HookPayload
+): void {
+ try {
+ const sessionId = payload.session_id;
+ if (!sessionId) return;
+
+ const timestamp = new Date().toISOString();
+
+ switch (eventType) {
+ case "SessionStart":
+ engine.startSession(sessionId);
+ break;
+
+ case "SessionEnd":
+ engine.endSession(sessionId, {
+ totalToolCalls: 0,
+ fileEditsCount: 0,
+ aiAuthoredLines: 0,
+ humanVerifiedLines: 0,
+ });
+ break;
+
+ case "PostToolUse":
+ engine.recordEvent({
+ sessionId,
+ eventType,
+ toolName: payload.tool_name ?? undefined,
+ timestamp,
+ });
+ break;
+
+ default:
+ // Other event types: record a generic event
+ engine.recordEvent({
+ sessionId,
+ eventType,
+ timestamp,
+ });
+ break;
+ }
+ } catch (error) {
+ // ARCH-3: Fail-open — analytics errors must never affect dispatch exit code
+ logError(
+ error instanceof Error ? error : new Error(String(error)),
+ { context: "executeAnalytics", eventType }
+ );
+ }
+}
// --- Stdin Reading ---
@@ -397,8 +506,10 @@ export function dispatch(
// Get handlers for this event
const handlers = getHandlersForEvent(config, eventType);
- // No handlers AND no declarative guards -> exit 0
- if (handlers.length === 0 && config.guards.length === 0) {
+ // No handlers AND no declarative guards AND no analytics AND no TUI -> exit 0
+ const hasAnalytics = config.analytics.enabled;
+ const hasTuiLaunch = eventType === "SessionStart" && config.tui?.autoLaunch;
+ if (handlers.length === 0 && config.guards.length === 0 && !hasAnalytics && !hasTuiLaunch) {
return { stdout: null, stderr: null, exitCode: 0 };
}
@@ -460,6 +571,21 @@ export function dispatch(
// Phase 3: Non-Blocking Side Effects (after stdout decided)
executeSideEffectPhase(handlers, payload);
+ // Phase 3b: TUI auto-launch on SessionStart (side effect)
+ if (eventType === "SessionStart" && config.tui?.autoLaunch) {
+ try {
+ launchTui(config.tui);
+ } catch {
+ // Fail-open: TUI launch failure must never affect dispatch (ARCH-3)
+ }
+ }
+
+ // Phase 3c: Built-in analytics (always runs if enabled, independent of custom handlers)
+ const analyticsEngine = getAnalyticsEngine(config.analytics);
+ if (analyticsEngine) {
+ executeAnalytics(analyticsEngine, eventType, payload);
+ }
+
// Merge warn context with Phase 2 context output
let finalStdout: string | null = contextStdout;
diff --git a/src/core/feeds/daemon-process.ts b/src/core/feeds/daemon-process.ts
index 0203610..9d5d01a 100644
--- a/src/core/feeds/daemon-process.ts
+++ b/src/core/feeds/daemon-process.ts
@@ -37,6 +37,9 @@ import { createProjectProducer } from "./producers/project.js";
import { createNewsProducer } from "./producers/news.js";
import { createCalendarProducer } from "./producers/calendar.js";
import { createInsightsProducer } from "./producers/insights.js";
+import { createPracticeProducer } from "./producers/practice.js";
+import { createWeatherProducer } from "./producers/weather.js";
+import { createMemoriesProducer } from "./producers/memories.js";
import { loadConfig } from "../config.js";
import type { HooksConfig, FeedDefinition } from "../types.js";
@@ -97,7 +100,7 @@ export function rotateLog(logFile: string = DEFAULT_DAEMON_LOG_PATH): void {
// --- Feed Registration ---
/**
- * Register the five built-in feeds (pulse, project, calendar, news, insights)
+ * Register the built-in feeds (pulse, project, calendar, news, insights, practice, weather, memories)
* based on the config.
*/
export function registerBuiltinFeeds(
@@ -144,6 +147,30 @@ export function registerBuiltinFeeds(
producer: createInsightsProducer(config.feeds.insights),
enabled: config.feeds.insights.enabled,
});
+
+ // Practice feed
+ registry.register({
+ name: "practice",
+ intervalSeconds: config.feeds.practice.intervalSeconds,
+ producer: createPracticeProducer(config.feeds.practice),
+ enabled: config.feeds.practice.enabled,
+ });
+
+ // Weather feed
+ registry.register({
+ name: "weather",
+ intervalSeconds: config.feeds.weather.intervalSeconds,
+ producer: createWeatherProducer(config.feeds.weather),
+ enabled: config.feeds.weather.enabled,
+ });
+
+ // Memories feed
+ registry.register({
+ name: "memories",
+ intervalSeconds: config.feeds.memories.intervalSeconds,
+ producer: createMemoriesProducer(config.feeds.memories),
+ enabled: config.feeds.memories.enabled,
+ });
}
/**
diff --git a/src/core/feeds/producers/memories.ts b/src/core/feeds/producers/memories.ts
new file mode 100644
index 0000000..b4571c2
--- /dev/null
+++ b/src/core/feeds/producers/memories.ts
@@ -0,0 +1,202 @@
+/**
+ * Memories feed producer: queries the hookwise analytics SQLite database
+ * for past sessions on the same calendar date in previous years, and also
+ * for notable sessions from 7 and 30 days ago.
+ *
+ * Shows "On This Day" nostalgia items in the status line, inspired by
+ * photo apps' memories features.
+ *
+ * Reads from the analytics database (default: ~/.hookwise/analytics.db)
+ * and writes to the "memories" cache key consumed by the memories segment.
+ *
+ * Returns null when:
+ * - The database file does not exist
+ * - The database cannot be opened or queried
+ * - No matching sessions are found
+ *
+ * ARCH-3: Fail-open — returns null on any error, never throws.
+ */
+
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { homedir } from "node:os";
+import Database from "better-sqlite3";
+import type { FeedProducer, MemoriesFeedConfig } from "../../types.js";
+
+export interface MemoryItem {
+ date: string; // YYYY-MM-DD of the remembered session
+ daysSince: number; // Days between that date and today
+ label: string; // Human-readable label, e.g. "1 year ago" or "7 days ago"
+ toolCalls: number; // Total tool calls in sessions on that date
+ filesEdited: number; // Total files edited in sessions on that date
+}
+
+export interface MemoriesData {
+ memories: MemoryItem[];
+ hasMemories: boolean;
+}
+
+/**
+ * Resolve a path that may start with ~/ to an absolute path.
+ */
+function resolvePath(p: string): string {
+ if (p.startsWith("~/")) {
+ return join(homedir(), p.slice(2));
+ }
+ return p;
+}
+
+/**
+ * Format a human-readable label for a memory based on the number of days since.
+ */
+function formatLabel(daysSince: number): string {
+ if (daysSince === 7) return "1 week ago";
+ if (daysSince === 30) return "1 month ago";
+
+ const years = Math.round(daysSince / 365);
+ if (years === 1) return "1 year ago";
+ if (years > 1) return `${years} years ago`;
+
+ const months = Math.round(daysSince / 30);
+ if (months === 1) return "1 month ago";
+ if (months > 1) return `${months} months ago`;
+
+ return `${daysSince} days ago`;
+}
+
+/**
+ * Query the analytics database for memory items.
+ *
+ * Looks for sessions from:
+ * 1. The same month+day in previous years
+ * 2. Exactly 7 days ago
+ * 3. Exactly 30 days ago
+ *
+ * For each matching date, aggregates tool call and file edit counts
+ * across all sessions on that date.
+ *
+ * Exported for direct testing — the producer factory wraps this.
+ *
+ * @param dbPath - Absolute path to the hookwise analytics SQLite database
+ * @returns MemoriesData or null if the database is inaccessible or no memories found
+ */
+export function queryMemoriesData(dbPath: string): MemoriesData | null {
+ const resolvedPath = resolvePath(dbPath);
+
+ if (!existsSync(resolvedPath)) return null;
+
+ let db: InstanceType | undefined;
+ try {
+ db = new Database(resolvedPath, { readonly: true });
+
+ const now = new Date();
+ const todayStr = now.toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
+ // Use UTC getters to stay consistent with toISOString() (which is UTC).
+ // Local getters diverge near midnight in negative UTC offsets.
+ const month = String(now.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(now.getUTCDate()).padStart(2, "0");
+ const monthDay = `-${month}-${day}`; // e.g. "-03-03"
+
+ // Collect all matching dates
+ const targetDates = new Set();
+
+ // 1. Same month+day in previous years
+ // Query sessions where started_at matches the current month-day but not today's date
+ const sameMonthDayRows = db
+ .prepare(
+ `SELECT DISTINCT substr(started_at, 1, 10) as session_date
+ FROM sessions
+ WHERE substr(started_at, 5, 6) = ?
+ AND substr(started_at, 1, 10) != ?`,
+ )
+ .all(monthDay, todayStr) as Array<{ session_date: string }>;
+
+ for (const row of sameMonthDayRows) {
+ targetDates.add(row.session_date);
+ }
+
+ // 2. Exactly 7 days ago
+ const sevenDaysAgo = new Date(now);
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
+ const sevenDaysAgoStr = sevenDaysAgo.toISOString().slice(0, 10);
+ targetDates.add(sevenDaysAgoStr);
+
+ // 3. Exactly 30 days ago
+ const thirtyDaysAgo = new Date(now);
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+ const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().slice(0, 10);
+ targetDates.add(thirtyDaysAgoStr);
+
+ // For each target date, aggregate sessions
+ const memories: MemoryItem[] = [];
+
+ for (const dateStr of targetDates) {
+ const sessionAgg = db
+ .prepare(
+ `SELECT
+ COUNT(*) as session_count,
+ COALESCE(SUM(total_tool_calls), 0) as total_tool_calls,
+ COALESCE(SUM(file_edits_count), 0) as total_files_edited
+ FROM sessions
+ WHERE substr(started_at, 1, 10) = ?`,
+ )
+ .get(dateStr) as {
+ session_count: number;
+ total_tool_calls: number;
+ total_files_edited: number;
+ };
+
+ if (!sessionAgg || sessionAgg.session_count === 0) continue;
+
+ // Use UTC date-only math to avoid timezone rounding issues
+ const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+ const [y, m, d] = dateStr.split("-").map(Number);
+ const targetUtc = Date.UTC(y, m - 1, d);
+ const daysSince = Math.round((todayUtc - targetUtc) / (1000 * 60 * 60 * 24));
+
+ if (daysSince <= 0) continue;
+
+ memories.push({
+ date: dateStr,
+ daysSince,
+ label: formatLabel(daysSince),
+ toolCalls: sessionAgg.total_tool_calls,
+ filesEdited: sessionAgg.total_files_edited,
+ });
+ }
+
+ if (memories.length === 0) return null;
+
+ // Sort by daysSince descending (oldest memory first, most nostalgic)
+ memories.sort((a, b) => b.daysSince - a.daysSince);
+
+ return {
+ memories,
+ hasMemories: true,
+ };
+ } catch {
+ // Database is corrupt, schema mismatch, or other error — fail-open
+ return null;
+ } finally {
+ try {
+ db?.close();
+ } catch {
+ // Ignore close errors
+ }
+ }
+}
+
+/**
+ * Create a FeedProducer for the memories feed.
+ *
+ * @param config - Memories feed configuration including database path
+ */
+export function createMemoriesProducer(
+ config: MemoriesFeedConfig,
+): FeedProducer {
+ return async (): Promise | null> => {
+ const result = queryMemoriesData(config.dbPath);
+ if (!result) return null;
+ return result as unknown as Record;
+ };
+}
diff --git a/src/core/feeds/producers/practice.ts b/src/core/feeds/producers/practice.ts
new file mode 100644
index 0000000..3f63f09
--- /dev/null
+++ b/src/core/feeds/producers/practice.ts
@@ -0,0 +1,120 @@
+/**
+ * Practice feed producer: queries the practice-tracker SQLite database
+ * for today's practice count, due SRS reviews, and last practice time.
+ *
+ * Reads from the practice-tracker database (default: ~/.practice-tracker/practice-tracker.db)
+ * and writes to the "practice" cache key consumed by two segments:
+ * - practice: displays "TARGET todayTotal today"
+ * - practice_breadcrumb: displays "Last practice: Xm ago"
+ *
+ * Returns null when:
+ * - The database file does not exist
+ * - The database cannot be opened or queried
+ *
+ * GH#8: This producer was missing — the practice segment existed with no data source.
+ */
+
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { homedir } from "node:os";
+import Database from "better-sqlite3";
+import type { FeedProducer, PracticeFeedConfig } from "../../types.js";
+
+export interface PracticeData {
+ todayTotal: number; // Number of practice reps completed today
+ dueReviews: number; // Number of SRS reviews due today or earlier
+ last_at: string | null; // ISO 8601 timestamp of most recent practice rep
+}
+
+/**
+ * Resolve a path that may start with ~/ to an absolute path.
+ */
+function resolvePath(p: string): string {
+ if (p.startsWith("~/")) {
+ return join(homedir(), p.slice(2));
+ }
+ return p;
+}
+
+/**
+ * Query the practice-tracker database for practice metrics.
+ *
+ * Opens the database read-only and runs three queries:
+ * 1. Count of today's practice reps
+ * 2. Most recent practiced_at timestamp
+ * 3. Count of SRS reviews due today or earlier
+ *
+ * Exported for direct testing — the producer factory wraps this.
+ *
+ * @param dbPath - Absolute path to the practice-tracker SQLite database
+ * @returns PracticeData or null if the database is inaccessible
+ */
+export function queryPracticeData(dbPath: string): PracticeData | null {
+ const resolvedPath = resolvePath(dbPath);
+
+ if (!existsSync(resolvedPath)) return null;
+
+ let db: InstanceType | undefined;
+ try {
+ db = new Database(resolvedPath, { readonly: true });
+
+ // UTC date: toISOString() returns UTC, and practice_reps.practiced_at
+ // is stored as ISO 8601 with Z suffix (UTC), so UTC-based "today" is
+ // the correct boundary for day-level comparisons.
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
+
+ // 1. Count today's practice reps
+ const todayCountRow = db
+ .prepare(
+ "SELECT COUNT(*) as cnt FROM practice_reps WHERE practiced_at >= ?",
+ )
+ .get(`${today}T00:00:00`) as { cnt: number };
+ const todayTotal = todayCountRow?.cnt ?? 0;
+
+ // 2. Most recent practiced_at
+ const lastRow = db
+ .prepare(
+ "SELECT MAX(practiced_at) as last_at FROM practice_reps",
+ )
+ .get() as { last_at: string | null };
+ const lastAt = lastRow?.last_at ?? null;
+
+ // 3. Count due SRS reviews (next_review_date <= today)
+ const dueRow = db
+ .prepare(
+ "SELECT COUNT(*) as cnt FROM question_srs_state WHERE next_review_date <= ?",
+ )
+ .get(today) as { cnt: number };
+ const dueReviews = dueRow?.cnt ?? 0;
+
+ return {
+ todayTotal,
+ dueReviews,
+ last_at: lastAt,
+ };
+ } catch {
+ // Database is corrupt, schema mismatch, or other error — fail-open
+ return null;
+ } finally {
+ try {
+ db?.close();
+ } catch {
+ // Ignore close errors
+ }
+ }
+}
+
+/**
+ * Create a FeedProducer for the practice feed.
+ *
+ * @param config - Practice feed configuration including database path
+ */
+export function createPracticeProducer(
+ config: PracticeFeedConfig,
+): FeedProducer {
+ return async (): Promise | null> => {
+ const result = queryPracticeData(config.dbPath);
+ if (!result) return null;
+ return result as unknown as Record;
+ };
+}
diff --git a/src/core/feeds/producers/weather.ts b/src/core/feeds/producers/weather.ts
new file mode 100644
index 0000000..9f5b223
--- /dev/null
+++ b/src/core/feeds/producers/weather.ts
@@ -0,0 +1,119 @@
+/**
+ * Weather feed producer: fetches current weather data from the Open-Meteo API.
+ *
+ * Implementation:
+ * 1. Builds the Open-Meteo API URL with configured coordinates and temperature unit
+ * 2. Fetches current weather data with a 10-second timeout
+ * 3. Parses the JSON response for temperature, weather code, and wind speed
+ * 4. Maps WMO weather codes to human-readable descriptions and emoji
+ * 5. Returns structured weather data for the cache bus
+ *
+ * Returns null on any failure (network error, malformed JSON, timeout).
+ * No API key required — Open-Meteo is a free, open-source weather API.
+ *
+ * Architecture: ARCH-3 fail-open — never throws, returns null on error.
+ */
+
+import type { WeatherFeedConfig, FeedProducer } from "../../types.js";
+
+export interface WeatherData {
+ temperature: number;
+ weatherCode: number;
+ windSpeed: number;
+ description: string;
+ emoji: string;
+ temperatureUnit: "fahrenheit" | "celsius";
+}
+
+/**
+ * WMO Weather interpretation codes (WW) mapped to description and emoji.
+ * See: https://open-meteo.com/en/docs#weathervariables
+ *
+ * Code ranges:
+ * 0 = Clear sky
+ * 1-3 = Mainly clear, partly cloudy, overcast
+ * 45, 48 = Fog, depositing rime fog
+ * 51-67 = Drizzle and rain (various intensities)
+ * 71-77 = Snow fall and snow grains
+ * 80-82 = Rain showers
+ * 85, 86 = Snow showers
+ * 95-99 = Thunderstorm (with and without hail)
+ */
+export function mapWeatherCode(code: number): { description: string; emoji: string } {
+ if (code === 0) return { description: "Clear", emoji: "\u2600\uFE0F" };
+ if (code >= 1 && code <= 3) return { description: "Cloudy", emoji: "\u26C5" };
+ if (code >= 45 && code <= 48) return { description: "Fog", emoji: "\uD83C\uDF2B\uFE0F" };
+ if (code >= 51 && code <= 67) return { description: "Rain", emoji: "\uD83C\uDF27\uFE0F" };
+ if (code >= 71 && code <= 77) return { description: "Snow", emoji: "\u2744\uFE0F" };
+ if (code >= 80 && code <= 82) return { description: "Showers", emoji: "\uD83C\uDF26\uFE0F" };
+ if (code >= 85 && code <= 86) return { description: "Snow Showers", emoji: "\uD83C\uDF28\uFE0F" };
+ if (code >= 95 && code <= 99) return { description: "Storm", emoji: "\u26C8\uFE0F" };
+ return { description: "Unknown", emoji: "\uD83C\uDF24\uFE0F" };
+}
+
+/**
+ * Build the Open-Meteo API URL for current weather conditions.
+ */
+function buildApiUrl(config: WeatherFeedConfig): string {
+ const { latitude, longitude, temperatureUnit } = config;
+ return `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code,wind_speed_10m&temperature_unit=${temperatureUnit}`;
+}
+
+/**
+ * Create a FeedProducer for the weather feed.
+ *
+ * @param config - Weather feed configuration (coordinates, temperature unit)
+ */
+export function createWeatherProducer(config: WeatherFeedConfig): FeedProducer {
+ return async (): Promise | null> => {
+ try {
+ const url = buildApiUrl(config);
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10_000);
+
+ let response: Response;
+ try {
+ response = await fetch(url, { signal: controller.signal });
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ if (!response.ok) return null;
+
+ const data = await response.json() as {
+ current?: {
+ temperature_2m?: number;
+ weather_code?: number;
+ wind_speed_10m?: number;
+ };
+ };
+
+ if (!data?.current) return null;
+
+ const temperature = data.current.temperature_2m;
+ const weatherCode = data.current.weather_code;
+ const windSpeed = data.current.wind_speed_10m;
+
+ if (temperature === undefined || weatherCode === undefined || windSpeed === undefined) {
+ return null;
+ }
+
+ const { description, emoji } = mapWeatherCode(weatherCode);
+
+ const result: WeatherData = {
+ temperature,
+ weatherCode,
+ windSpeed,
+ description,
+ emoji,
+ temperatureUnit: config.temperatureUnit,
+ };
+
+ return result as unknown as Record;
+ } catch {
+ // ARCH-3: Fail-open — return null on any error
+ return null;
+ }
+ };
+}
diff --git a/src/core/status-line/segments.ts b/src/core/status-line/segments.ts
index f257d57..000979a 100644
--- a/src/core/status-line/segments.ts
+++ b/src/core/status-line/segments.ts
@@ -375,6 +375,62 @@ const insights_trend: SegmentRenderer = (cache) => {
return `\uD83D\uDD27 Top: ${toolNames} | Peak: ${peakLabel}`;
};
+// --- Weather Segment ---
+
+const weather: SegmentRenderer = (cache) => {
+ const weatherData = cache.weather as
+ | (CacheEntry & {
+ temperature?: number;
+ emoji?: string;
+ windSpeed?: number;
+ temperatureUnit?: "fahrenheit" | "celsius";
+ })
+ | undefined;
+ if (!weatherData || !isFresh(weatherData)) return "\uD83C\uDF24\uFE0F --";
+ if (weatherData.temperature === undefined || !weatherData.emoji) return "\uD83C\uDF24\uFE0F --";
+
+ const unit = weatherData.temperatureUnit === "celsius" ? "C" : "F";
+ const temp = Math.round(weatherData.temperature);
+ let text = `${weatherData.emoji} ${temp}\u00B0${unit}`;
+
+ // Append wind indicator if wind speed exceeds 20 mph/kmh
+ if (weatherData.windSpeed !== undefined && weatherData.windSpeed > 20) {
+ text += " \uD83D\uDCA8";
+ }
+
+ return text;
+};
+
+// --- Memories Segment ---
+
+interface MemoryItemShape {
+ date: string;
+ daysSince: number;
+ label: string;
+ toolCalls: number;
+ filesEdited: number;
+}
+
+const memories: SegmentRenderer = (cache) => {
+ const memoriesData = cache.memories as
+ | (CacheEntry & { memories?: MemoryItemShape[]; hasMemories?: boolean })
+ | undefined;
+ if (!memoriesData || !isFresh(memoriesData)) return "";
+ if (!memoriesData.hasMemories || !memoriesData.memories?.length) return "";
+
+ // Pick the most interesting memory: the one with the most tool calls
+ const best = memoriesData.memories.reduce((a, b) =>
+ b.toolCalls > a.toolCalls ? b : a,
+ );
+
+ const sessionCount = memoriesData.memories.reduce((sum, m) => {
+ // Each memory is a date, count it as 1+ sessions
+ return sum + 1;
+ }, 0);
+
+ return `\uD83D\uDD70\uFE0F On this day: ${sessionCount} session${sessionCount !== 1 ? "s" : ""} (${best.label})`;
+};
+
/**
* Registry of all built-in segments.
*/
@@ -398,4 +454,6 @@ export const BUILTIN_SEGMENTS: Record = {
insights_friction,
insights_pace,
insights_trend,
+ weather,
+ memories,
};
diff --git a/src/core/tui-launcher.ts b/src/core/tui-launcher.ts
new file mode 100644
index 0000000..5624e5d
--- /dev/null
+++ b/src/core/tui-launcher.ts
@@ -0,0 +1,250 @@
+/**
+ * TUI auto-launch utility for hookwise.
+ *
+ * Manages launching the Python-based Textual TUI app as a detached process.
+ * Uses a PID file to prevent duplicate instances.
+ *
+ * Fail-open: All errors are caught and logged, never thrown (ARCH-3).
+ * Cross-platform: macOS (Darwin) is the primary target; other platforms
+ * log a warning and skip the launch.
+ */
+
+import { spawn } from "node:child_process";
+import {
+ existsSync,
+ openSync,
+ closeSync,
+ readFileSync,
+ writeFileSync,
+ unlinkSync,
+ constants as fsConstants,
+} from "node:fs";
+import { join, dirname } from "node:path";
+import { platform } from "node:os";
+import { ensureDir } from "./state.js";
+import { logError, logDebug } from "./errors.js";
+import type { TuiConfig } from "./types.js";
+import { getStateDir } from "./state.js";
+
+/** Default TUI PID file path: ~/.hookwise/tui.pid */
+function getDefaultTuiPidPath(): string {
+ return join(getStateDir(), "tui.pid");
+}
+
+// --- Internal helpers ---
+
+/**
+ * Read the PID from the TUI PID file.
+ * Returns null if the file does not exist or contains invalid content.
+ */
+function readTuiPid(pidPath: string): number | null {
+ try {
+ if (!existsSync(pidPath)) return null;
+ const content = readFileSync(pidPath, "utf-8").trim();
+ const pid = parseInt(content, 10);
+ if (Number.isNaN(pid) || pid <= 0) return null;
+ return pid;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Write a PID to the TUI PID file.
+ * Uses O_EXCL flag to prevent TOCTOU races.
+ * If the file already exists (EEXIST), re-checks process liveness before overwriting.
+ */
+function writeTuiPid(pid: number, pidPath: string): void {
+ ensureDir(dirname(pidPath));
+ try {
+ writeFileSync(pidPath, String(pid), { encoding: "utf-8", flag: "wx" });
+ } catch (err: unknown) {
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
+ const existingPid = readTuiPid(pidPath);
+ if (existingPid !== null && isProcessAlive(existingPid)) {
+ return; // Another TUI instance is running
+ }
+ // Stale PID file -- safe to overwrite
+ writeFileSync(pidPath, String(pid), "utf-8");
+ } else {
+ throw err;
+ }
+ }
+}
+
+/**
+ * Atomically reserve the PID file by creating it with O_EXCL.
+ * Writes a sentinel value ("0") to indicate "launching".
+ * Returns true if reservation succeeded, false if file already exists
+ * (another launch is in progress or a live TUI holds it).
+ */
+function reserveTuiPid(pidPath: string): boolean {
+ ensureDir(dirname(pidPath));
+ try {
+ const fd = openSync(pidPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL);
+ writeFileSync(fd, "0", "utf-8");
+ closeSync(fd);
+ return true;
+ } catch (err: unknown) {
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
+ // Check if the existing file is from a live process
+ const existingPid = readTuiPid(pidPath);
+ if (existingPid !== null && existingPid > 0 && isProcessAlive(existingPid)) {
+ return false; // Another TUI instance is running
+ }
+ // Stale PID file (dead process or sentinel "0") — remove and retry once
+ try {
+ unlinkSync(pidPath);
+ const fd = openSync(pidPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL);
+ writeFileSync(fd, "0", "utf-8");
+ closeSync(fd);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+ }
+}
+
+/**
+ * Remove the TUI PID file if it exists.
+ */
+function removeTuiPid(pidPath: string): void {
+ try {
+ if (existsSync(pidPath)) {
+ unlinkSync(pidPath);
+ }
+ } catch {
+ // Ignore cleanup errors
+ }
+}
+
+/**
+ * Check if a process with the given PID is alive.
+ * Uses signal 0 (existence check).
+ */
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// --- Public API ---
+
+/**
+ * Check if the TUI is currently running.
+ *
+ * Reads the PID file and checks process liveness via signal 0.
+ * Cleans up stale PID files (process dead but PID file exists).
+ *
+ * @param pidPath - Path to the TUI PID file (default: ~/.hookwise/tui.pid)
+ */
+export function isTuiRunning(pidPath?: string): boolean {
+ const effectivePath = pidPath ?? getDefaultTuiPidPath();
+ const pid = readTuiPid(effectivePath);
+ if (pid === null) return false;
+
+ if (isProcessAlive(pid)) {
+ return true;
+ }
+
+ // Stale PID file -- process is dead but file remains
+ removeTuiPid(effectivePath);
+ return false;
+}
+
+/**
+ * Launch the TUI as a detached process.
+ *
+ * - Checks if TUI is already running (duplicate prevention)
+ * - On macOS: spawns via `open -a Terminal.app` for newWindow, or detached for background
+ * - Writes PID to ~/.hookwise/tui.pid
+ * - Fail-open: all errors caught and logged, never thrown
+ *
+ * @param config - TUI configuration
+ * @param pidPath - Path to the TUI PID file (default: ~/.hookwise/tui.pid)
+ * @returns true if the TUI was launched, false otherwise
+ */
+export function launchTui(config: TuiConfig, pidPath?: string): boolean {
+ const effectivePath = pidPath ?? getDefaultTuiPidPath();
+
+ try {
+ // Duplicate prevention
+ if (isTuiRunning(effectivePath)) {
+ logDebug("TUI already running, skipping launch");
+ return false;
+ }
+
+ const currentPlatform = platform();
+
+ // Cross-platform: only macOS is supported
+ if (currentPlatform !== "darwin") {
+ logDebug(`TUI auto-launch not supported on platform: ${currentPlatform}`);
+ return false;
+ }
+
+ if (config.launchMethod === "newWindow") {
+ // macOS: use osascript to open a new Terminal.app window running the TUI.
+ // Note: osascript is transient — its PID is not the python3 PID, so we
+ // skip PID-based duplicate prevention for newWindow mode.
+ const child = spawn(
+ "osascript",
+ ["-e", 'tell application "Terminal" to do script "python3 -m hookwise_tui"'],
+ {
+ detached: true,
+ stdio: "ignore",
+ },
+ );
+ child.unref();
+ const pid = child.pid ?? null;
+ if (pid !== null) {
+ logDebug(`TUI launched with PID ${pid} (method: newWindow)`);
+ return true;
+ }
+ logDebug("TUI launch failed: no PID returned from spawn");
+ return false;
+ }
+
+ // Background mode: reserve PID file atomically BEFORE spawning
+ // to prevent TOCTOU race where two concurrent calls both pass isTuiRunning()
+ if (!reserveTuiPid(effectivePath)) {
+ logDebug("TUI PID file reservation failed (another instance launching or running)");
+ return false;
+ }
+
+ const child = spawn(
+ "python3",
+ ["-m", "hookwise_tui"],
+ {
+ detached: true,
+ stdio: "ignore",
+ },
+ );
+
+ const pid = child.pid ?? null;
+ child.unref();
+
+ if (pid !== null) {
+ // Update sentinel with actual PID
+ writeTuiPid(pid, effectivePath);
+ logDebug(`TUI launched with PID ${pid} (method: background)`);
+ return true;
+ }
+
+ // Spawn failed — release the reservation
+ removeTuiPid(effectivePath);
+ logDebug("TUI launch failed: no PID returned from spawn");
+ return false;
+ } catch (error) {
+ // ARCH-3: Fail-open -- TUI launch failure must never affect dispatch
+ logError(
+ error instanceof Error ? error : new Error(String(error)),
+ { context: "launchTui" },
+ );
+ return false;
+ }
+}
diff --git a/src/core/types.ts b/src/core/types.ts
index 9fbd40e..a30fc5c 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -83,6 +83,7 @@ export interface HooksConfig {
includes: string[];
feeds: FeedsConfig;
daemon: DaemonConfig;
+ tui: TuiConfig;
}
export interface CoachingConfig {
@@ -530,6 +531,8 @@ export interface CalendarFeedConfig {
intervalSeconds: number;
lookaheadMinutes: number;
calendars: string[];
+ credentialsPath: string;
+ tokenPath: string;
}
export interface NewsFeedConfig {
@@ -556,12 +559,35 @@ export interface InsightsFeedConfig {
usageDataPath: string;
}
+export interface PracticeFeedConfig {
+ enabled: boolean;
+ intervalSeconds: number;
+ dbPath: string;
+}
+
+export interface WeatherFeedConfig {
+ enabled: boolean;
+ intervalSeconds: number;
+ latitude: number;
+ longitude: number;
+ temperatureUnit: "fahrenheit" | "celsius";
+}
+
+export interface MemoriesFeedConfig {
+ enabled: boolean;
+ intervalSeconds: number;
+ dbPath: string;
+}
+
export interface FeedsConfig {
pulse: PulseFeedConfig;
project: ProjectFeedConfig;
calendar: CalendarFeedConfig;
news: NewsFeedConfig;
insights: InsightsFeedConfig;
+ practice: PracticeFeedConfig;
+ weather: WeatherFeedConfig;
+ memories: MemoriesFeedConfig;
custom: CustomFeedConfig[];
}
@@ -570,3 +596,8 @@ export interface DaemonConfig {
inactivityTimeoutMinutes: number;
logFile: string;
}
+
+export interface TuiConfig {
+ autoLaunch: boolean;
+ launchMethod: "newWindow" | "background";
+}
diff --git a/tests/cli/doctor-feeds.test.tsx b/tests/cli/doctor-feeds.test.tsx
index a4a3c15..8ddabc2 100644
--- a/tests/cli/doctor-feeds.test.tsx
+++ b/tests/cli/doctor-feeds.test.tsx
@@ -85,6 +85,9 @@ describe("DoctorCommand — feed checks", () => {
calendarEnabled?: boolean;
newsEnabled?: boolean;
insightsEnabled?: boolean;
+ practiceEnabled?: boolean;
+ weatherEnabled?: boolean;
+ memoriesEnabled?: boolean;
} = {}): void {
const {
pulseEnabled = true,
@@ -92,6 +95,9 @@ describe("DoctorCommand — feed checks", () => {
calendarEnabled = false,
newsEnabled = false,
insightsEnabled = false,
+ practiceEnabled = false,
+ weatherEnabled = false,
+ memoriesEnabled = false,
} = overrides;
const configYaml = [
@@ -129,6 +135,17 @@ describe("DoctorCommand — feed checks", () => {
" interval_seconds: 120",
" staleness_days: 30",
" usage_data_path: ~/.claude/usage-data",
+ " practice:",
+ ` enabled: ${practiceEnabled}`,
+ " interval_seconds: 120",
+ " db_path: ~/.practice-tracker/practice-tracker.db",
+ " weather:",
+ ` enabled: ${weatherEnabled}`,
+ " interval_seconds: 600",
+ " temperature_unit: fahrenheit",
+ " memories:",
+ ` enabled: ${memoriesEnabled}`,
+ " interval_seconds: 3600",
].join("\n") + "\n";
writeFileSync(join(tempDir, "hookwise.yaml"), configYaml, "utf-8");
}
diff --git a/tests/cli/init.test.tsx b/tests/cli/init.test.tsx
index 4b46c69..ef2f802 100644
--- a/tests/cli/init.test.tsx
+++ b/tests/cli/init.test.tsx
@@ -177,7 +177,7 @@ describe("InitCommand", () => {
// --- Feed preset tests (Task 8.2) ---
- it("full preset enables all 5 feeds", async () => {
+ it("full preset enables all 6 feeds", async () => {
render();
const { loadConfig } = await import("../../src/core/config.js");
@@ -187,6 +187,7 @@ describe("InitCommand", () => {
expect(config.feeds.calendar.enabled).toBe(true);
expect(config.feeds.news.enabled).toBe(true);
expect(config.feeds.insights.enabled).toBe(true);
+ expect(config.feeds.practice.enabled).toBe(true);
});
it("full preset enables daemon autoStart", async () => {
diff --git a/tests/cli/setup.test.ts b/tests/cli/setup.test.ts
index 0c31541..4b31aa0 100644
--- a/tests/cli/setup.test.ts
+++ b/tests/cli/setup.test.ts
@@ -1,13 +1,15 @@
/**
* Tests for the setup CLI command.
*
- * Captures console.log output and mocks environment variables.
+ * Captures console.log output and mocks environment variables, fs, and child_process.
* Does NOT mock React/Ink — setup commands use plain stdout.
*
- * Covers Task 7.2:
- * - setup calendar: prints setup instructions when no client ID
- * - setup calendar: prints not implemented when client ID present
- * - setup calendar: prints already configured when credentials exist
+ * Covers:
+ * - setup calendar: prints already configured when token exists
+ * - setup calendar: prints instructions when env vars missing
+ * - setup calendar: writes credentials JSON when env vars present
+ * - setup calendar: runs OAuth flow via Python script
+ * - setup calendar: handles OAuth flow failure gracefully
* - setup unknown: prints error for unknown target
*
* Requirements: FR-10.1, FR-10.2, FR-10.3, FR-10.4, FR-10.5, NFR-3
@@ -21,13 +23,28 @@ vi.mock("node:fs", async () => {
return {
...actual,
existsSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ };
+});
+
+// --- Mock node:child_process ---
+vi.mock("node:child_process", async () => {
+ const actual = await vi.importActual("node:child_process");
+ return {
+ ...actual,
+ execFileSync: vi.fn(),
};
});
import { runSetupCommand } from "../../src/cli/commands/setup.js";
-import { existsSync } from "node:fs";
+import { existsSync, writeFileSync, mkdirSync } from "node:fs";
+import { execFileSync } from "node:child_process";
const mockedExistsSync = vi.mocked(existsSync);
+const mockedWriteFileSync = vi.mocked(writeFileSync);
+const mockedMkdirSync = vi.mocked(mkdirSync);
+const mockedExecFileSync = vi.mocked(execFileSync);
describe("setup CLI command", () => {
let logSpy: ReturnType;
@@ -35,6 +52,7 @@ describe("setup CLI command", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
+ vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
@@ -53,7 +71,18 @@ describe("setup CLI command", () => {
});
describe("calendar", () => {
- it("prints setup instructions when no client ID env vars are set", async () => {
+ it("prints already configured when token file exists", async () => {
+ // Token file exists — already set up
+ mockedExistsSync.mockReturnValue(true);
+
+ await runSetupCommand("calendar");
+
+ const allOutput = logSpy.mock.calls.map((c) => c[0]).join("\n");
+ expect(allOutput).toContain("already configured");
+ expect(process.exitCode).toBeUndefined();
+ });
+
+ it("prints setup instructions when no env vars are set", async () => {
mockedExistsSync.mockReturnValue(false);
await runSetupCommand("calendar");
@@ -67,25 +96,118 @@ describe("setup CLI command", () => {
expect(process.exitCode).toBe(1);
});
- it("prints not implemented when client ID is present", async () => {
+ it("writes credentials JSON when env vars are present", async () => {
+ // Token doesn't exist, credentials don't exist
mockedExistsSync.mockReturnValue(false);
+ // Make Python setup succeed and final token check succeed
+ mockedExecFileSync.mockReturnValue("Calendar OAuth setup complete. Token saved to /tmp/token.json");
+ process.env.HOOKWISE_GOOGLE_CLIENT_ID = "test-client-id";
+ process.env.HOOKWISE_GOOGLE_CLIENT_SECRET = "test-client-secret";
+
+ // After OAuth flow, token exists on final check
+ let callCount = 0;
+ mockedExistsSync.mockImplementation((path: unknown) => {
+ const p = String(path);
+ // First call: token check (false)
+ // Second call: credentials check (false — need to write)
+ // Third call: final token verification (true)
+ if (p.includes("calendar-token")) {
+ callCount++;
+ return callCount > 1; // false first time, true second time
+ }
+ return false;
+ });
+
+ await runSetupCommand("calendar");
+
+ // Should have written the credentials file
+ expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
+ const writtenContent = JSON.parse(mockedWriteFileSync.mock.calls[0][1] as string);
+ expect(writtenContent.installed.client_id).toBe("test-client-id");
+ expect(writtenContent.installed.client_secret).toBe("test-client-secret");
+ expect(writtenContent.installed.auth_uri).toBe("https://accounts.google.com/o/oauth2/auth");
+ expect(writtenContent.installed.token_uri).toBe("https://oauth2.googleapis.com/token");
+ expect(writtenContent.installed.redirect_uris).toEqual(["http://localhost"]);
+ });
+
+ it("runs Python script in --setup mode when env vars are present", async () => {
process.env.HOOKWISE_GOOGLE_CLIENT_ID = "test-client-id";
process.env.HOOKWISE_GOOGLE_CLIENT_SECRET = "test-client-secret";
+ let tokenCheckCount = 0;
+ mockedExistsSync.mockImplementation((path: unknown) => {
+ const p = String(path);
+ if (p.includes("calendar-token")) {
+ tokenCheckCount++;
+ return tokenCheckCount > 1; // false first, true after OAuth
+ }
+ return false; // credentials file doesn't exist
+ });
+
+ mockedExecFileSync.mockReturnValue("Calendar OAuth setup complete.\n");
+
await runSetupCommand("calendar");
+ // Verify Python script was called with --setup
+ expect(mockedExecFileSync).toHaveBeenCalledTimes(1);
+ const args = mockedExecFileSync.mock.calls[0];
+ expect(args[0]).toBe("python3");
+ expect(args[1]).toContain("--setup");
+ expect(args[1]).toContain("--credentials");
+
const allOutput = logSpy.mock.calls.map((c) => c[0]).join("\n");
- expect(allOutput).toContain("Google API credentials detected");
- expect(allOutput).toContain("OAuth flow not yet implemented. Coming in a future release.");
+ expect(allOutput).toContain("Calendar setup complete");
+ expect(process.exitCode).toBeUndefined();
});
- it("prints already configured when credentials file exists", async () => {
- mockedExistsSync.mockReturnValue(true);
+ it("prints error when OAuth flow fails gracefully (no crash)", async () => {
+ process.env.HOOKWISE_GOOGLE_CLIENT_ID = "test-client-id";
+ process.env.HOOKWISE_GOOGLE_CLIENT_SECRET = "test-client-secret";
+
+ // Token never exists
+ mockedExistsSync.mockReturnValue(false);
+
+ // Python script throws
+ const execError = new Error("Python script failed") as Error & { stderr: string };
+ execError.stderr = "OAuth consent was cancelled";
+ mockedExecFileSync.mockImplementation(() => {
+ throw execError;
+ });
await runSetupCommand("calendar");
+ // Should not crash — fail-open principle
+ const allErrors = errorSpy.mock.calls.map((c) => c[0]).join("\n");
+ expect(allErrors).toContain("OAuth");
+ expect(process.exitCode).toBe(2);
+ });
+
+ it("skips writing credentials if file already exists", async () => {
+ process.env.HOOKWISE_GOOGLE_CLIENT_ID = "test-client-id";
+ process.env.HOOKWISE_GOOGLE_CLIENT_SECRET = "test-client-secret";
+
+ let tokenCheckCount = 0;
+ mockedExistsSync.mockImplementation((path: unknown) => {
+ const p = String(path);
+ if (p.includes("calendar-token")) {
+ tokenCheckCount++;
+ return tokenCheckCount > 1; // false first, true after OAuth
+ }
+ if (p.includes("calendar-credentials")) {
+ return true; // credentials already exist
+ }
+ return false;
+ });
+
+ mockedExecFileSync.mockReturnValue("Calendar OAuth setup complete.\n");
+
+ await runSetupCommand("calendar");
+
+ // Should NOT have written credentials — they already existed
+ expect(mockedWriteFileSync).not.toHaveBeenCalled();
+
const allOutput = logSpy.mock.calls.map((c) => c[0]).join("\n");
- expect(allOutput).toContain("already configured");
+ expect(allOutput).toContain("Using existing credentials");
});
});
diff --git a/tests/core/feeds-config.test.ts b/tests/core/feeds-config.test.ts
index 6eda4ac..cf5ae7d 100644
--- a/tests/core/feeds-config.test.ts
+++ b/tests/core/feeds-config.test.ts
@@ -64,6 +64,12 @@ describe("feeds and daemon default config", () => {
expect(config.feeds.news.maxStories).toBe(5);
expect(config.feeds.news.rotationMinutes).toBe(30);
+ // Practice
+ expect(config.feeds.practice).toBeDefined();
+ expect(config.feeds.practice.enabled).toBe(true);
+ expect(config.feeds.practice.intervalSeconds).toBe(120);
+ expect(config.feeds.practice.dbPath).toContain("practice-tracker.db");
+
// Custom
expect(config.feeds.custom).toEqual([]);
@@ -74,10 +80,12 @@ describe("feeds and daemon default config", () => {
expect(config.daemon.logFile).toContain("daemon.log");
});
- it("default config has pulse and project enabled, calendar and news disabled", () => {
+ it("default config has pulse, project, insights, and practice enabled; calendar and news disabled", () => {
const config = getDefaultConfig();
expect(config.feeds.pulse.enabled).toBe(true);
expect(config.feeds.project.enabled).toBe(true);
+ expect(config.feeds.insights.enabled).toBe(true);
+ expect(config.feeds.practice.enabled).toBe(true);
expect(config.feeds.calendar.enabled).toBe(false);
expect(config.feeds.news.enabled).toBe(false);
});
@@ -99,6 +107,8 @@ describe("feeds and daemon default config", () => {
expect(config.feeds.project.enabled).toBe(true);
expect(config.feeds.calendar.enabled).toBe(false);
expect(config.feeds.news.enabled).toBe(false);
+ expect(config.feeds.practice.enabled).toBe(true);
+ expect(config.feeds.practice.intervalSeconds).toBe(120);
expect(config.feeds.custom).toEqual([]);
expect(config.daemon.autoStart).toBe(true);
expect(config.daemon.inactivityTimeoutMinutes).toBe(120);
diff --git a/tests/core/feeds/daemon-process.test.ts b/tests/core/feeds/daemon-process.test.ts
index c169105..e9be129 100644
--- a/tests/core/feeds/daemon-process.test.ts
+++ b/tests/core/feeds/daemon-process.test.ts
@@ -6,7 +6,7 @@
* No real daemon processes are spawned.
*
* Covers Task 6.2:
- * - registerBuiltinFeeds: registers all 4 built-in feeds
+ * - registerBuiltinFeeds: registers all 8 built-in feeds
* - registerCustomFeeds: registers custom feeds from config
* - staggered intervals: offsets by index * 2000ms
* - feed error isolation: one failing feed doesn't crash others
@@ -62,6 +62,10 @@ vi.mock("../../../src/core/feeds/producers/calendar.js", () => ({
createCalendarProducer: vi.fn(() => vi.fn(async () => ({ events: [] }))),
}));
+vi.mock("../../../src/core/feeds/producers/practice.js", () => ({
+ createPracticeProducer: vi.fn(() => vi.fn(async () => ({ todayTotal: 0, dueReviews: 0, last_at: null }))),
+}));
+
// --- Mock cache-bus ---
vi.mock("../../../src/core/feeds/cache-bus.js", () => ({
mergeKey: vi.fn(),
@@ -208,17 +212,17 @@ describe("rotateLog", () => {
// --- registerBuiltinFeeds ---
describe("registerBuiltinFeeds", () => {
- it("registers all 5 built-in feeds (FR-2.3)", () => {
+ it("registers all built-in feeds (FR-2.3)", () => {
const registry = createFeedRegistry();
const config = makeTestConfig();
registerBuiltinFeeds(registry, config, TEST_CACHE_PATH);
const all = registry.getAll();
- expect(all).toHaveLength(5);
+ expect(all).toHaveLength(8);
const names = all.map((f) => f.name).sort();
- expect(names).toEqual(["calendar", "insights", "news", "project", "pulse"]);
+ expect(names).toEqual(["calendar", "insights", "memories", "news", "practice", "project", "pulse", "weather"]);
});
it("uses correct interval from config for each feed", () => {
@@ -266,7 +270,7 @@ describe("registerBuiltinFeeds", () => {
const enabled = registry.getEnabled();
const names = enabled.map((f) => f.name).sort();
- expect(names).toEqual(["insights", "project", "pulse"]);
+ expect(names).toEqual(["insights", "practice", "project", "pulse"]);
});
});
diff --git a/tests/core/feeds/producers/memories.test.ts b/tests/core/feeds/producers/memories.test.ts
new file mode 100644
index 0000000..1521a09
--- /dev/null
+++ b/tests/core/feeds/producers/memories.test.ts
@@ -0,0 +1,342 @@
+/**
+ * Tests for the memories feed producer.
+ *
+ * Covers:
+ * - Finding sessions from the same month+day in previous years
+ * - Finding sessions from exactly 7 days ago
+ * - Finding sessions from exactly 30 days ago
+ * - Missing database returns null (fail-open)
+ * - No matching sessions returns null
+ * - Memory item has correct shape (date, daysSince, label, toolCalls, filesEdited)
+ * - Most interesting memory selection by tool call count
+ *
+ * GH#80: Memories/On This Day feed producer
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import Database from "better-sqlite3";
+import {
+ createMemoriesProducer,
+ queryMemoriesData,
+} from "../../../../src/core/feeds/producers/memories.js";
+import type { MemoriesFeedConfig } from "../../../../src/core/types.js";
+
+// --- Helpers ---
+
+function makeConfig(overrides: Partial = {}): MemoriesFeedConfig {
+ return {
+ enabled: true,
+ intervalSeconds: 3600,
+ dbPath: "", // will be overridden per-test
+ ...overrides,
+ };
+}
+
+/**
+ * Create the minimal hookwise analytics schema in a test database.
+ * Only creates the tables the memories producer needs to query.
+ */
+function createTestSchema(db: Database.Database): void {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ started_at TEXT NOT NULL,
+ ended_at TEXT,
+ duration_seconds INTEGER,
+ total_tool_calls INTEGER DEFAULT 0,
+ file_edits_count INTEGER DEFAULT 0,
+ ai_authored_lines INTEGER DEFAULT 0,
+ human_verified_lines INTEGER DEFAULT 0,
+ classification TEXT,
+ estimated_cost_usd REAL DEFAULT 0.0
+ );
+
+ CREATE TABLE IF NOT EXISTS events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ tool_name TEXT,
+ timestamp TEXT NOT NULL,
+ file_path TEXT,
+ lines_added INTEGER DEFAULT 0,
+ lines_removed INTEGER DEFAULT 0,
+ ai_confidence_score REAL,
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
+ );
+ `);
+}
+
+/**
+ * Insert a session with given date and stats.
+ */
+function insertSession(
+ db: Database.Database,
+ id: string,
+ date: string,
+ toolCalls: number = 0,
+ fileEdits: number = 0,
+): void {
+ db.prepare(
+ "INSERT INTO sessions (id, started_at, total_tool_calls, file_edits_count) VALUES (?, ?, ?, ?)",
+ ).run(id, `${date}T10:00:00Z`, toolCalls, fileEdits);
+}
+
+/**
+ * Get today's date as YYYY-MM-DD.
+ */
+function todayStr(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+/**
+ * Get the same month+day as today but in a previous year.
+ */
+function sameMonthDayPreviousYear(yearsAgo: number): string {
+ const now = new Date();
+ const year = now.getUTCFullYear() - yearsAgo;
+ const month = String(now.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(now.getUTCDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+/**
+ * Get a date string N days ago.
+ */
+function daysAgoStr(n: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() - n);
+ return d.toISOString().slice(0, 10);
+}
+
+// --- queryMemoriesData unit tests ---
+
+describe("queryMemoriesData", () => {
+ let tempRoot: string;
+ let dbPath: string;
+ let db: Database.Database;
+
+ beforeEach(() => {
+ tempRoot = mkdtempSync(join(tmpdir(), "hookwise-memories-"));
+ dbPath = join(tempRoot, "analytics.db");
+ db = new Database(dbPath);
+ createTestSchema(db);
+ });
+
+ afterEach(() => {
+ db.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ });
+
+ it("returns null when database file does not exist", () => {
+ const result = queryMemoriesData(join(tempRoot, "nonexistent.db"));
+ expect(result).toBeNull();
+ });
+
+ it("returns null when no matching sessions found (empty DB)", () => {
+ const result = queryMemoriesData(dbPath);
+ expect(result).toBeNull();
+ });
+
+ it("returns null when only today's sessions exist (no nostalgia)", () => {
+ insertSession(db, "session-today", todayStr(), 10, 5);
+ const result = queryMemoriesData(dbPath);
+ expect(result).toBeNull();
+ });
+
+ it("finds sessions from the same month+day in a previous year", () => {
+ const lastYear = sameMonthDayPreviousYear(1);
+ insertSession(db, "session-last-year", lastYear, 15, 8);
+
+ const result = queryMemoriesData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.hasMemories).toBe(true);
+ expect(result!.memories.length).toBeGreaterThanOrEqual(1);
+
+ const yearAgoMemory = result!.memories.find(
+ (m) => m.date === lastYear,
+ );
+ expect(yearAgoMemory).toBeDefined();
+ expect(yearAgoMemory!.toolCalls).toBe(15);
+ expect(yearAgoMemory!.filesEdited).toBe(8);
+ // daysSince should be approximately 365
+ expect(yearAgoMemory!.daysSince).toBeGreaterThanOrEqual(364);
+ expect(yearAgoMemory!.daysSince).toBeLessThanOrEqual(366);
+ });
+
+ it("finds sessions from exactly 7 days ago", () => {
+ const sevenDaysAgo = daysAgoStr(7);
+ insertSession(db, "session-7d", sevenDaysAgo, 20, 12);
+
+ const result = queryMemoriesData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.hasMemories).toBe(true);
+
+ const weekAgoMemory = result!.memories.find(
+ (m) => m.date === sevenDaysAgo,
+ );
+ expect(weekAgoMemory).toBeDefined();
+ expect(weekAgoMemory!.daysSince).toBe(7);
+ expect(weekAgoMemory!.label).toBe("1 week ago");
+ expect(weekAgoMemory!.toolCalls).toBe(20);
+ });
+
+ it("finds sessions from exactly 30 days ago", () => {
+ const thirtyDaysAgo = daysAgoStr(30);
+ insertSession(db, "session-30d", thirtyDaysAgo, 25, 10);
+
+ const result = queryMemoriesData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.hasMemories).toBe(true);
+
+ const monthAgoMemory = result!.memories.find(
+ (m) => m.date === thirtyDaysAgo,
+ );
+ expect(monthAgoMemory).toBeDefined();
+ expect(monthAgoMemory!.daysSince).toBe(30);
+ expect(monthAgoMemory!.label).toBe("1 month ago");
+ expect(monthAgoMemory!.toolCalls).toBe(25);
+ });
+
+ it("memory item has correct shape", () => {
+ const sevenDaysAgo = daysAgoStr(7);
+ insertSession(db, "session-shape", sevenDaysAgo, 5, 3);
+
+ const result = queryMemoriesData(dbPath);
+ expect(result).not.toBeNull();
+
+ const memory = result!.memories.find((m) => m.date === sevenDaysAgo);
+ expect(memory).toBeDefined();
+ expect(memory).toEqual(
+ expect.objectContaining({
+ date: expect.any(String),
+ daysSince: expect.any(Number),
+ label: expect.any(String),
+ toolCalls: expect.any(Number),
+ filesEdited: expect.any(Number),
+ }),
+ );
+ });
+
+ it("aggregates tool calls across multiple sessions on the same date", () => {
+ const sevenDaysAgo = daysAgoStr(7);
+ insertSession(db, "session-7d-1", sevenDaysAgo, 10, 3);
+ insertSession(db, "session-7d-2", sevenDaysAgo, 15, 7);
+
+ const result = queryMemoriesData(dbPath);
+ expect(result).not.toBeNull();
+
+ const memory = result!.memories.find((m) => m.date === sevenDaysAgo);
+ expect(memory).toBeDefined();
+ expect(memory!.toolCalls).toBe(25); // 10 + 15
+ expect(memory!.filesEdited).toBe(10); // 3 + 7
+ });
+
+ it("returns memories sorted by daysSince descending (oldest first)", () => {
+ const sevenDaysAgo = daysAgoStr(7);
+ const thirtyDaysAgo = daysAgoStr(30);
+ insertSession(db, "session-7", sevenDaysAgo, 5, 2);
+ insertSession(db, "session-30", thirtyDaysAgo, 8, 4);
+
+ const result = queryMemoriesData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.memories.length).toBeGreaterThanOrEqual(2);
+
+ // 30 days should come before 7 days (descending order)
+ const thirty = result!.memories.find((m) => m.date === thirtyDaysAgo);
+ const seven = result!.memories.find((m) => m.date === sevenDaysAgo);
+ expect(thirty).toBeDefined();
+ expect(seven).toBeDefined();
+
+ const thirtyIndex = result!.memories.indexOf(thirty!);
+ const sevenIndex = result!.memories.indexOf(seven!);
+ expect(thirtyIndex).toBeLessThan(sevenIndex);
+ });
+
+ it("returns null when 7d and 30d lookbacks have no sessions and no same-month-day matches", () => {
+ // Freeze time so 7d/30d/same-month-day are deterministic and don't match 2025-01-15
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-02-22T12:00:00Z"));
+ insertSession(db, "session-random", "2025-01-15", 10, 5);
+ const result = queryMemoriesData(dbPath);
+ expect(result).toBeNull();
+ vi.useRealTimers();
+ });
+});
+
+// --- createMemoriesProducer integration tests ---
+
+describe("createMemoriesProducer", () => {
+ let tempRoot: string;
+ let dbPath: string;
+ let db: Database.Database;
+
+ beforeEach(() => {
+ tempRoot = mkdtempSync(join(tmpdir(), "hookwise-memories-prod-"));
+ dbPath = join(tempRoot, "analytics.db");
+ db = new Database(dbPath);
+ createTestSchema(db);
+ });
+
+ afterEach(() => {
+ db.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ });
+
+ it("returns memories data from a real database", async () => {
+ const sevenDaysAgo = daysAgoStr(7);
+ insertSession(db, "session-7d", sevenDaysAgo, 42, 18);
+
+ const config = makeConfig({ dbPath });
+ const producer = createMemoriesProducer(config);
+ const result = await producer();
+
+ expect(result).not.toBeNull();
+ expect(result!.hasMemories).toBe(true);
+ expect(result!.memories).toBeDefined();
+ expect(Array.isArray(result!.memories)).toBe(true);
+ });
+
+ it("returns null when database does not exist", async () => {
+ const config = makeConfig({ dbPath: join(tempRoot, "nope.db") });
+ const producer = createMemoriesProducer(config);
+ const result = await producer();
+ expect(result).toBeNull();
+ });
+
+ it("returns null when no matching sessions exist", async () => {
+ // Empty DB — no sessions at all
+ const config = makeConfig({ dbPath });
+ const producer = createMemoriesProducer(config);
+ const result = await producer();
+ expect(result).toBeNull();
+ });
+
+ it("selects most interesting memory by tool call count for segment rendering", async () => {
+ const sevenDaysAgo = daysAgoStr(7);
+ const thirtyDaysAgo = daysAgoStr(30);
+ // 7 days ago: many tool calls
+ insertSession(db, "session-7d", sevenDaysAgo, 100, 30);
+ // 30 days ago: fewer tool calls
+ insertSession(db, "session-30d", thirtyDaysAgo, 10, 2);
+
+ const config = makeConfig({ dbPath });
+ const producer = createMemoriesProducer(config);
+ const result = await producer();
+
+ expect(result).not.toBeNull();
+ const memories = result!.memories as Array<{
+ toolCalls: number;
+ date: string;
+ }>;
+
+ // Find the memory with the most tool calls
+ const best = memories.reduce((a, b) =>
+ b.toolCalls > a.toolCalls ? b : a,
+ );
+ expect(best.toolCalls).toBe(100);
+ expect(best.date).toBe(sevenDaysAgo);
+ });
+});
diff --git a/tests/core/feeds/producers/practice.test.ts b/tests/core/feeds/producers/practice.test.ts
new file mode 100644
index 0000000..4337d4b
--- /dev/null
+++ b/tests/core/feeds/producers/practice.test.ts
@@ -0,0 +1,233 @@
+/**
+ * Tests for the practice feed producer.
+ *
+ * Covers:
+ * - Today's practice count from practice_reps table
+ * - Last practice timestamp (last_at)
+ * - Due reviews count from question_srs_state
+ * - Missing database returns null
+ * - Empty database returns zero counts
+ * - Database path resolution (~/ expansion)
+ *
+ * GH#8: Practice segment exists but had no producer
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import Database from "better-sqlite3";
+import {
+ createPracticeProducer,
+ queryPracticeData,
+} from "../../../../src/core/feeds/producers/practice.js";
+import type { PracticeFeedConfig } from "../../../../src/core/types.js";
+
+// --- Helpers ---
+
+function makeConfig(overrides: Partial = {}): PracticeFeedConfig {
+ return {
+ enabled: true,
+ intervalSeconds: 120,
+ dbPath: "", // will be overridden per-test
+ ...overrides,
+ };
+}
+
+/**
+ * Create a minimal practice-tracker schema in a test database.
+ * Only creates the tables the producer needs to query.
+ */
+function createTestSchema(db: Database.Database): void {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS practice_reps (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ question_id INTEGER NOT NULL,
+ rep_number INTEGER NOT NULL,
+ practiced_at TEXT NOT NULL DEFAULT (datetime('now')),
+ time_spent_minutes REAL NOT NULL CHECK(time_spent_minutes > 0),
+ quality INTEGER NOT NULL CHECK(quality BETWEEN 0 AND 5),
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ `);
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS question_srs_state (
+ question_id INTEGER PRIMARY KEY,
+ next_review_date TEXT NOT NULL,
+ last_quality INTEGER NOT NULL CHECK(last_quality BETWEEN 0 AND 5),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ `);
+}
+
+// --- queryPracticeData unit tests ---
+
+describe("queryPracticeData", () => {
+ let tempRoot: string;
+ let dbPath: string;
+ let db: Database.Database;
+
+ beforeEach(() => {
+ tempRoot = mkdtempSync(join(tmpdir(), "hookwise-practice-"));
+ dbPath = join(tempRoot, "practice-tracker.db");
+ db = new Database(dbPath);
+ createTestSchema(db);
+ });
+
+ afterEach(() => {
+ db.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ });
+
+ it("returns zero counts for an empty database", () => {
+ const result = queryPracticeData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.todayTotal).toBe(0);
+ expect(result!.dueReviews).toBe(0);
+ expect(result!.last_at).toBeNull();
+ });
+
+ it("counts today's practice reps correctly", () => {
+ const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
+ const todayTs = `${today}T10:00:00Z`;
+ const todayTs2 = `${today}T14:00:00Z`;
+
+ db.prepare(
+ "INSERT INTO practice_reps (question_id, rep_number, practiced_at, time_spent_minutes, quality) VALUES (?, ?, ?, ?, ?)",
+ ).run(1, 1, todayTs, 15, 4);
+ db.prepare(
+ "INSERT INTO practice_reps (question_id, rep_number, practiced_at, time_spent_minutes, quality) VALUES (?, ?, ?, ?, ?)",
+ ).run(2, 1, todayTs2, 20, 3);
+
+ const result = queryPracticeData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.todayTotal).toBe(2);
+ });
+
+ it("does not count yesterday's practice reps in todayTotal", () => {
+ const yesterday = new Date(Date.now() - 86_400_000)
+ .toISOString()
+ .slice(0, 10);
+ const yesterdayTs = `${yesterday}T10:00:00Z`;
+
+ db.prepare(
+ "INSERT INTO practice_reps (question_id, rep_number, practiced_at, time_spent_minutes, quality) VALUES (?, ?, ?, ?, ?)",
+ ).run(1, 1, yesterdayTs, 15, 4);
+
+ const result = queryPracticeData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.todayTotal).toBe(0);
+ });
+
+ it("returns last_at as the most recent practiced_at timestamp", () => {
+ const today = new Date().toISOString().slice(0, 10);
+ const earlyTs = `${today}T08:00:00Z`;
+ const lateTs = `${today}T16:00:00Z`;
+
+ db.prepare(
+ "INSERT INTO practice_reps (question_id, rep_number, practiced_at, time_spent_minutes, quality) VALUES (?, ?, ?, ?, ?)",
+ ).run(1, 1, earlyTs, 15, 4);
+ db.prepare(
+ "INSERT INTO practice_reps (question_id, rep_number, practiced_at, time_spent_minutes, quality) VALUES (?, ?, ?, ?, ?)",
+ ).run(2, 1, lateTs, 20, 3);
+
+ const result = queryPracticeData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.last_at).toBe(lateTs);
+ });
+
+ it("counts due reviews (next_review_date <= today)", () => {
+ const today = new Date().toISOString().slice(0, 10);
+ const yesterday = new Date(Date.now() - 86_400_000)
+ .toISOString()
+ .slice(0, 10);
+ const tomorrow = new Date(Date.now() + 86_400_000)
+ .toISOString()
+ .slice(0, 10);
+
+ // Due: today and yesterday
+ db.prepare(
+ "INSERT INTO question_srs_state (question_id, next_review_date, last_quality) VALUES (?, ?, ?)",
+ ).run(1, today, 3);
+ db.prepare(
+ "INSERT INTO question_srs_state (question_id, next_review_date, last_quality) VALUES (?, ?, ?)",
+ ).run(2, yesterday, 2);
+ // Not due: tomorrow
+ db.prepare(
+ "INSERT INTO question_srs_state (question_id, next_review_date, last_quality) VALUES (?, ?, ?)",
+ ).run(3, tomorrow, 4);
+
+ const result = queryPracticeData(dbPath);
+ expect(result).not.toBeNull();
+ expect(result!.dueReviews).toBe(2);
+ });
+
+ it("returns null when database file does not exist", () => {
+ const result = queryPracticeData(join(tempRoot, "nonexistent.db"));
+ expect(result).toBeNull();
+ });
+});
+
+// --- createPracticeProducer integration tests ---
+
+describe("createPracticeProducer", () => {
+ let tempRoot: string;
+ let dbPath: string;
+ let db: Database.Database;
+
+ beforeEach(() => {
+ tempRoot = mkdtempSync(join(tmpdir(), "hookwise-practice-prod-"));
+ dbPath = join(tempRoot, "practice-tracker.db");
+ db = new Database(dbPath);
+ createTestSchema(db);
+ });
+
+ afterEach(() => {
+ db.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ });
+
+ it("returns practice data from a real database", async () => {
+ const today = new Date().toISOString().slice(0, 10);
+ const todayTs = `${today}T10:00:00Z`;
+
+ db.prepare(
+ "INSERT INTO practice_reps (question_id, rep_number, practiced_at, time_spent_minutes, quality) VALUES (?, ?, ?, ?, ?)",
+ ).run(1, 1, todayTs, 15, 4);
+
+ db.prepare(
+ "INSERT INTO question_srs_state (question_id, next_review_date, last_quality) VALUES (?, ?, ?)",
+ ).run(1, today, 3);
+ db.prepare(
+ "INSERT INTO question_srs_state (question_id, next_review_date, last_quality) VALUES (?, ?, ?)",
+ ).run(2, today, 2);
+
+ const config = makeConfig({ dbPath });
+ const producer = createPracticeProducer(config);
+ const result = await producer();
+
+ expect(result).not.toBeNull();
+ expect(result!.todayTotal).toBe(1);
+ expect(result!.dueReviews).toBe(2);
+ expect(result!.last_at).toBe(todayTs);
+ });
+
+ it("returns null when database does not exist", async () => {
+ const config = makeConfig({ dbPath: join(tempRoot, "nope.db") });
+ const producer = createPracticeProducer(config);
+ const result = await producer();
+ expect(result).toBeNull();
+ });
+
+ it("returns data even when all counts are zero", async () => {
+ const config = makeConfig({ dbPath });
+ const producer = createPracticeProducer(config);
+ const result = await producer();
+
+ expect(result).not.toBeNull();
+ expect(result!.todayTotal).toBe(0);
+ expect(result!.dueReviews).toBe(0);
+ expect(result!.last_at).toBeNull();
+ });
+});
diff --git a/tests/core/feeds/producers/weather.test.ts b/tests/core/feeds/producers/weather.test.ts
new file mode 100644
index 0000000..098b69d
--- /dev/null
+++ b/tests/core/feeds/producers/weather.test.ts
@@ -0,0 +1,316 @@
+/**
+ * Tests for the weather feed producer.
+ *
+ * Covers:
+ * - Successful weather fetch and cache shape
+ * - Fail-open on network error (ARCH-3)
+ * - Fail-open on malformed JSON
+ * - WMO weather code to emoji mapping
+ * - Temperature unit handling (fahrenheit/celsius)
+ * - Wind speed indicator threshold
+ * - Fail-open on API timeout
+ *
+ * All HTTP calls are mocked via vi.stubGlobal("fetch").
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { createWeatherProducer, mapWeatherCode } from "../../../../src/core/feeds/producers/weather.js";
+import type { WeatherFeedConfig } from "../../../../src/core/types.js";
+import type { WeatherData } from "../../../../src/core/feeds/producers/weather.js";
+
+// --- Helpers ---
+
+function makeConfig(overrides?: Partial): WeatherFeedConfig {
+ return {
+ enabled: true,
+ intervalSeconds: 600,
+ latitude: 37.7749,
+ longitude: -122.4194,
+ temperatureUnit: "fahrenheit",
+ ...overrides,
+ };
+}
+
+/**
+ * Create a mock Response object matching the Fetch API.
+ */
+function mockResponse(body: unknown, ok = true): Response {
+ return {
+ ok,
+ status: ok ? 200 : 500,
+ json: async () => body,
+ text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
+ } as Response;
+}
+
+/**
+ * Sample Open-Meteo API response for clear weather in San Francisco.
+ */
+const SAMPLE_WEATHER_RESPONSE = {
+ current: {
+ temperature_2m: 72.1,
+ weather_code: 0,
+ wind_speed_10m: 8.5,
+ },
+};
+
+/**
+ * Set up a mock fetch that returns weather data.
+ */
+function mockWeatherFetch(
+ responseBody: unknown = SAMPLE_WEATHER_RESPONSE,
+ ok = true,
+) {
+ const mockFetch = vi.fn(async () => mockResponse(responseBody, ok));
+ vi.stubGlobal("fetch", mockFetch);
+ return mockFetch;
+}
+
+// --- Tests ---
+
+describe("createWeatherProducer — successful fetch", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("fetches weather data and returns correct cache shape", async () => {
+ mockWeatherFetch();
+ const producer = createWeatherProducer(makeConfig());
+ const result = (await producer()) as WeatherData | null;
+
+ expect(result).not.toBeNull();
+ expect(result!.temperature).toBe(72.1);
+ expect(result!.weatherCode).toBe(0);
+ expect(result!.windSpeed).toBe(8.5);
+ expect(result!.description).toBe("Clear");
+ expect(result!.emoji).toBe("\u2600\uFE0F");
+ expect(result!.temperatureUnit).toBe("fahrenheit");
+ });
+
+ it("builds the correct API URL with configured coordinates", async () => {
+ const mockFetch = mockWeatherFetch();
+ const config = makeConfig({ latitude: 40.7128, longitude: -74.006 });
+ const producer = createWeatherProducer(config);
+ await producer();
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
+ expect(calledUrl).toContain("latitude=40.7128");
+ expect(calledUrl).toContain("longitude=-74.006");
+ });
+
+ it("includes temperature_unit in API URL", async () => {
+ const mockFetch = mockWeatherFetch();
+ const config = makeConfig({ temperatureUnit: "celsius" });
+ const producer = createWeatherProducer(config);
+ await producer();
+
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
+ expect(calledUrl).toContain("temperature_unit=celsius");
+ });
+
+ it("returns celsius unit in result when configured", async () => {
+ const celsiusResponse = {
+ current: {
+ temperature_2m: 22.3,
+ weather_code: 1,
+ wind_speed_10m: 5.0,
+ },
+ };
+ mockWeatherFetch(celsiusResponse);
+ const config = makeConfig({ temperatureUnit: "celsius" });
+ const producer = createWeatherProducer(config);
+ const result = (await producer()) as WeatherData | null;
+
+ expect(result).not.toBeNull();
+ expect(result!.temperature).toBe(22.3);
+ expect(result!.temperatureUnit).toBe("celsius");
+ });
+
+ it("returns weather with wind data", async () => {
+ const windyResponse = {
+ current: {
+ temperature_2m: 58.0,
+ weather_code: 61,
+ wind_speed_10m: 25.3,
+ },
+ };
+ mockWeatherFetch(windyResponse);
+ const producer = createWeatherProducer(makeConfig());
+ const result = (await producer()) as WeatherData | null;
+
+ expect(result).not.toBeNull();
+ expect(result!.windSpeed).toBe(25.3);
+ expect(result!.description).toBe("Rain");
+ expect(result!.emoji).toBe("\uD83C\uDF27\uFE0F");
+ });
+});
+
+describe("createWeatherProducer — fail-open (ARCH-3)", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("returns null when fetch throws a network error", async () => {
+ const mockFetch = vi.fn(async () => {
+ throw new Error("Network error");
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when API response is not ok (500)", async () => {
+ mockWeatherFetch(null, false);
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null on malformed JSON (missing current field)", async () => {
+ mockWeatherFetch({ latitude: 37.7749 }); // no 'current' field
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when current.temperature_2m is undefined", async () => {
+ mockWeatherFetch({
+ current: {
+ weather_code: 0,
+ wind_speed_10m: 5,
+ },
+ });
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when current.weather_code is undefined", async () => {
+ mockWeatherFetch({
+ current: {
+ temperature_2m: 72,
+ wind_speed_10m: 5,
+ },
+ });
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when current.wind_speed_10m is undefined", async () => {
+ mockWeatherFetch({
+ current: {
+ temperature_2m: 72,
+ weather_code: 0,
+ },
+ });
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when fetch is aborted (timeout simulation)", async () => {
+ const mockFetch = vi.fn(async () => {
+ const error = new DOMException("The operation was aborted.", "AbortError");
+ throw error;
+ });
+ vi.stubGlobal("fetch", mockFetch);
+
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when JSON parsing throws", async () => {
+ const mockFetch = vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ json: async () => { throw new SyntaxError("Unexpected token"); },
+ text: async () => "not json",
+ }));
+ vi.stubGlobal("fetch", mockFetch);
+
+ const producer = createWeatherProducer(makeConfig());
+ const result = await producer();
+
+ expect(result).toBeNull();
+ });
+});
+
+describe("mapWeatherCode — WMO code to description and emoji", () => {
+ it("maps code 0 to Clear with sun emoji", () => {
+ const result = mapWeatherCode(0);
+ expect(result.description).toBe("Clear");
+ expect(result.emoji).toBe("\u2600\uFE0F");
+ });
+
+ it("maps codes 1-3 to Cloudy", () => {
+ expect(mapWeatherCode(1).description).toBe("Cloudy");
+ expect(mapWeatherCode(2).description).toBe("Cloudy");
+ expect(mapWeatherCode(3).description).toBe("Cloudy");
+ expect(mapWeatherCode(1).emoji).toBe("\u26C5");
+ });
+
+ it("maps codes 45-48 to Fog", () => {
+ expect(mapWeatherCode(45).description).toBe("Fog");
+ expect(mapWeatherCode(48).description).toBe("Fog");
+ expect(mapWeatherCode(45).emoji).toBe("\uD83C\uDF2B\uFE0F");
+ });
+
+ it("maps codes 51-67 to Rain", () => {
+ expect(mapWeatherCode(51).description).toBe("Rain");
+ expect(mapWeatherCode(61).description).toBe("Rain");
+ expect(mapWeatherCode(67).description).toBe("Rain");
+ expect(mapWeatherCode(51).emoji).toBe("\uD83C\uDF27\uFE0F");
+ });
+
+ it("maps codes 71-77 to Snow", () => {
+ expect(mapWeatherCode(71).description).toBe("Snow");
+ expect(mapWeatherCode(77).description).toBe("Snow");
+ expect(mapWeatherCode(71).emoji).toBe("\u2744\uFE0F");
+ });
+
+ it("maps codes 80-82 to Showers", () => {
+ expect(mapWeatherCode(80).description).toBe("Showers");
+ expect(mapWeatherCode(82).description).toBe("Showers");
+ expect(mapWeatherCode(80).emoji).toBe("\uD83C\uDF26\uFE0F");
+ });
+
+ it("maps codes 85-86 to Snow Showers", () => {
+ expect(mapWeatherCode(85).description).toBe("Snow Showers");
+ expect(mapWeatherCode(86).description).toBe("Snow Showers");
+ });
+
+ it("maps codes 95-99 to Storm", () => {
+ expect(mapWeatherCode(95).description).toBe("Storm");
+ expect(mapWeatherCode(99).description).toBe("Storm");
+ expect(mapWeatherCode(95).emoji).toBe("\u26C8\uFE0F");
+ });
+
+ it("returns Unknown for unrecognized codes", () => {
+ expect(mapWeatherCode(4).description).toBe("Unknown");
+ expect(mapWeatherCode(100).description).toBe("Unknown");
+ expect(mapWeatherCode(-1).description).toBe("Unknown");
+ });
+
+ it("returns Unknown for gap codes (e.g., 10, 44, 68-70, 78-79, 83-84, 87-94)", () => {
+ expect(mapWeatherCode(10).description).toBe("Unknown");
+ expect(mapWeatherCode(44).description).toBe("Unknown");
+ expect(mapWeatherCode(68).description).toBe("Unknown");
+ expect(mapWeatherCode(78).description).toBe("Unknown");
+ expect(mapWeatherCode(83).description).toBe("Unknown");
+ expect(mapWeatherCode(90).description).toBe("Unknown");
+ });
+});
diff --git a/tests/core/feeds/segments.test.ts b/tests/core/feeds/segments.test.ts
index c572033..12b46e6 100644
--- a/tests/core/feeds/segments.test.ts
+++ b/tests/core/feeds/segments.test.ts
@@ -519,11 +519,12 @@ describe("BUILTIN_SEGMENTS registry", () => {
"context_bar", "mode_badge", "duration", "practice_breadcrumb",
"pulse", "project", "calendar", "news",
"insights_friction", "insights_pace", "insights_trend",
+ "weather", "memories",
];
for (const name of expected) {
expect(BUILTIN_SEGMENTS[name]).toBeDefined();
expect(typeof BUILTIN_SEGMENTS[name]).toBe("function");
}
- expect(Object.keys(BUILTIN_SEGMENTS)).toHaveLength(19);
+ expect(Object.keys(BUILTIN_SEGMENTS)).toHaveLength(21);
});
});
diff --git a/tests/core/status-line/segments-new.test.ts b/tests/core/status-line/segments-new.test.ts
index 4557bbf..cc67a9f 100644
--- a/tests/core/status-line/segments-new.test.ts
+++ b/tests/core/status-line/segments-new.test.ts
@@ -189,7 +189,7 @@ describe("BUILTIN_SEGMENTS registry", () => {
}
});
- it("has 19 total segments", () => {
- expect(Object.keys(BUILTIN_SEGMENTS)).toHaveLength(19);
+ it("has 21 total segments", () => {
+ expect(Object.keys(BUILTIN_SEGMENTS)).toHaveLength(21);
});
});
diff --git a/tests/core/tui-launcher.test.ts b/tests/core/tui-launcher.test.ts
new file mode 100644
index 0000000..d809671
--- /dev/null
+++ b/tests/core/tui-launcher.test.ts
@@ -0,0 +1,337 @@
+/**
+ * Tests for the TUI auto-launcher utility.
+ *
+ * Covers:
+ * - PID file creation/reading
+ * - isTuiRunning() returns false when no PID file
+ * - isTuiRunning() returns false when PID file has dead process
+ * - launchTui() creates PID file on success
+ * - launchTui() skips if TUI already running (duplicate prevention)
+ * - launchTui() returns false on unsupported platforms
+ * - Fail-open: errors are caught and logged, never thrown
+ * - Dispatcher integration: SessionStart with tui.autoLaunch doesn't crash
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import {
+ mkdtempSync,
+ writeFileSync,
+ readFileSync,
+ rmSync,
+ existsSync,
+} from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+
+// Mock child_process.spawn before importing the module under test
+vi.mock("node:child_process", () => ({
+ spawn: vi.fn(),
+ spawnSync: vi.fn(),
+}));
+
+// Mock node:os platform() so we can control cross-platform behavior
+vi.mock("node:os", async () => {
+ const actual = await vi.importActual("node:os");
+ return {
+ ...actual,
+ platform: vi.fn(() => "darwin"),
+ };
+});
+
+import { isTuiRunning, launchTui } from "../../src/core/tui-launcher.js";
+import { spawn } from "node:child_process";
+import { platform } from "node:os";
+import { dispatch } from "../../src/core/dispatcher.js";
+import { getDefaultConfig } from "../../src/core/config.js";
+import type { TuiConfig, HookPayload } from "../../src/core/types.js";
+
+const mockedSpawn = vi.mocked(spawn);
+const mockedPlatform = vi.mocked(platform);
+
+function makePayload(overrides: Partial = {}): HookPayload {
+ return {
+ session_id: "test-session-tui",
+ ...overrides,
+ };
+}
+
+describe("isTuiRunning", () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), "hookwise-tui-test-"));
+ });
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ it("returns false when no PID file exists", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ expect(isTuiRunning(pidPath)).toBe(false);
+ });
+
+ it("returns false when PID file has dead process", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ // Use a PID that is very unlikely to exist (max PID)
+ writeFileSync(pidPath, "999999999", "utf-8");
+ expect(isTuiRunning(pidPath)).toBe(false);
+ });
+
+ it("cleans up stale PID file when process is dead", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ writeFileSync(pidPath, "999999999", "utf-8");
+
+ expect(isTuiRunning(pidPath)).toBe(false);
+ // PID file should have been cleaned up
+ expect(existsSync(pidPath)).toBe(false);
+ });
+
+ it("returns true when PID file points to a live process", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ // Use the current process PID (guaranteed alive)
+ writeFileSync(pidPath, String(process.pid), "utf-8");
+ expect(isTuiRunning(pidPath)).toBe(true);
+ });
+
+ it("returns false when PID file contains invalid content", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ writeFileSync(pidPath, "not-a-number", "utf-8");
+ expect(isTuiRunning(pidPath)).toBe(false);
+ });
+
+ it("returns false when PID file is empty", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ writeFileSync(pidPath, "", "utf-8");
+ expect(isTuiRunning(pidPath)).toBe(false);
+ });
+});
+
+describe("launchTui", () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), "hookwise-tui-launch-"));
+ vi.clearAllMocks();
+ mockedPlatform.mockReturnValue("darwin");
+ });
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ it("creates PID file on successful launch", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+
+ // Mock spawn to return a fake child process
+ const fakeChild = {
+ pid: 12345,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ const result = launchTui(config, pidPath);
+
+ expect(result).toBe(true);
+ expect(existsSync(pidPath)).toBe(true);
+ const writtenPid = readFileSync(pidPath, "utf-8").trim();
+ expect(writtenPid).toBe("12345");
+ });
+
+ it("skips launch if TUI is already running (duplicate prevention)", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ // Write a PID file with the current process PID (alive)
+ writeFileSync(pidPath, String(process.pid), "utf-8");
+
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+ const result = launchTui(config, pidPath);
+
+ expect(result).toBe(false);
+ // spawn should NOT have been called
+ expect(mockedSpawn).not.toHaveBeenCalled();
+ });
+
+ it("returns false on unsupported platform", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+
+ mockedPlatform.mockReturnValue("linux");
+
+ const result = launchTui(config, pidPath);
+
+ expect(result).toBe(false);
+ expect(mockedSpawn).not.toHaveBeenCalled();
+ });
+
+ it("spawns with correct args for newWindow method on macOS", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "newWindow" };
+
+ const fakeChild = {
+ pid: 54321,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ launchTui(config, pidPath);
+
+ expect(mockedSpawn).toHaveBeenCalledWith(
+ "osascript",
+ ["-e", 'tell application "Terminal" to do script "python3 -m hookwise_tui"'],
+ expect.objectContaining({ detached: true, stdio: "ignore" }),
+ );
+ });
+
+ it("does not write PID file for newWindow method (transient osascript PID)", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "newWindow" };
+
+ const fakeChild = {
+ pid: 54321,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ const result = launchTui(config, pidPath);
+
+ expect(result).toBe(true);
+ // No PID file for newWindow — osascript PID is transient
+ expect(existsSync(pidPath)).toBe(false);
+ });
+
+ it("spawns with correct args for background method", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+
+ const fakeChild = {
+ pid: 67890,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ launchTui(config, pidPath);
+
+ expect(mockedSpawn).toHaveBeenCalledWith(
+ "python3",
+ ["-m", "hookwise_tui"],
+ expect.objectContaining({ detached: true, stdio: "ignore" }),
+ );
+ });
+
+ it("calls unref() on the child process to allow parent exit", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+
+ const fakeChild = {
+ pid: 11111,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ launchTui(config, pidPath);
+
+ expect(fakeChild.unref).toHaveBeenCalled();
+ });
+
+ it("returns false when spawn returns no PID", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+
+ const fakeChild = {
+ pid: undefined,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ const result = launchTui(config, pidPath);
+
+ expect(result).toBe(false);
+ // Should not have created a PID file
+ expect(existsSync(pidPath)).toBe(false);
+ });
+
+ it("fail-open: spawn error does not throw", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+
+ mockedSpawn.mockImplementation(() => {
+ throw new Error("spawn ENOENT");
+ });
+
+ // Should NOT throw
+ const result = launchTui(config, pidPath);
+ expect(result).toBe(false);
+ });
+
+ it("overwrites stale PID file and launches new instance", () => {
+ const pidPath = join(tempDir, "tui.pid");
+ // Write a stale PID (dead process)
+ writeFileSync(pidPath, "999999999", "utf-8");
+
+ const config: TuiConfig = { autoLaunch: true, launchMethod: "background" };
+ const fakeChild = {
+ pid: 22222,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ const result = launchTui(config, pidPath);
+
+ expect(result).toBe(true);
+ const writtenPid = readFileSync(pidPath, "utf-8").trim();
+ expect(writtenPid).toBe("22222");
+ });
+});
+
+describe("dispatch — TUI auto-launch integration", () => {
+ it("SessionStart with tui.autoLaunch:true does not crash (fail-open)", () => {
+ const config = getDefaultConfig();
+ config.tui = { autoLaunch: true, launchMethod: "newWindow" };
+
+ // spawn is mocked, so it won't actually launch anything
+ const fakeChild = {
+ pid: 33333,
+ unref: vi.fn(),
+ };
+ mockedSpawn.mockReturnValue(fakeChild as unknown as ReturnType);
+
+ const result = dispatch("SessionStart", makePayload(), { config });
+
+ // Must not crash — fail-open
+ expect(result.exitCode).toBe(0);
+ });
+
+ it("SessionStart with tui.autoLaunch:false does not call launchTui", () => {
+ const config = getDefaultConfig();
+ config.tui = { autoLaunch: false, launchMethod: "newWindow" };
+
+ mockedSpawn.mockClear();
+
+ const result = dispatch("SessionStart", makePayload(), { config });
+
+ expect(result.exitCode).toBe(0);
+ // spawn should not have been called for TUI (daemon may still call it)
+ // We check that no spawn was called with osascript or python3 -m for TUI
+ const tuiSpawnCalls = mockedSpawn.mock.calls.filter(
+ (call) => call[0] === "osascript" || (call[0] === "python3" && call[1]?.includes("-m")),
+ );
+ expect(tuiSpawnCalls.length).toBe(0);
+ });
+
+ it("non-SessionStart events do not trigger TUI launch", () => {
+ const config = getDefaultConfig();
+ config.tui = { autoLaunch: true, launchMethod: "background" };
+
+ mockedSpawn.mockClear();
+
+ const result = dispatch("PreToolUse", makePayload(), { config });
+
+ expect(result.exitCode).toBe(0);
+ // No TUI-related spawn calls for non-SessionStart events
+ const tuiSpawnCalls = mockedSpawn.mock.calls.filter(
+ (call) => call[0] === "python3" && call[1]?.includes("-m"),
+ );
+ expect(tuiSpawnCalls.length).toBe(0);
+ });
+});
diff --git a/tests/integration/dispatcher-wiring.test.ts b/tests/integration/dispatcher-wiring.test.ts
new file mode 100644
index 0000000..d0182a8
--- /dev/null
+++ b/tests/integration/dispatcher-wiring.test.ts
@@ -0,0 +1,272 @@
+/**
+ * Integration tests for dispatcher → side-effect → state wiring.
+ *
+ * Verifies that dispatch() writes expected state to real cache files
+ * and databases. Uses real temp directories and real dispatch() calls
+ * (ARCH-1: no mocking of internal modules).
+ *
+ * Tasks: 3.1 (heartbeat/cwd cache writes)
+ * 3.2 (analytics DB wiring — skipped for GH#9)
+ * 3.3 (fail-open and fault isolation)
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { existsSync, writeFileSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { dispatch } from "../../src/core/dispatcher.js";
+import { readKey } from "../../src/core/feeds/cache-bus.js";
+import { createTestEnv, makePayload, readAnalyticsDB } from "./helpers.js";
+import type { TestEnv } from "./helpers.js";
+import type { CacheEntry } from "../../src/core/types.js";
+
+// ---------------------------------------------------------------------------
+// Task 3.1 — Heartbeat and CWD cache writes
+// ---------------------------------------------------------------------------
+
+describe("dispatcher-wiring: heartbeat and CWD cache writes", () => {
+ let env: TestEnv;
+ const savedStateDir = process.env.HOOKWISE_STATE_DIR;
+
+ beforeEach(() => {
+ env = createTestEnv();
+ // Point HOOKWISE_STATE_DIR to temp dir so loadConfig() doesn't touch ~/.hookwise
+ process.env.HOOKWISE_STATE_DIR = env.tmpDir;
+ });
+
+ afterEach(() => {
+ env.cleanup();
+ if (savedStateDir !== undefined) {
+ process.env.HOOKWISE_STATE_DIR = savedStateDir;
+ } else {
+ delete process.env.HOOKWISE_STATE_DIR;
+ }
+ });
+
+ it("dispatch(any event) writes _heartbeat to real cache file with recent timestamp", () => {
+ const beforeMs = Date.now();
+
+ dispatch("PostToolUse", makePayload(), {
+ config: env.config,
+ projectDir: env.tmpDir,
+ });
+
+ const afterMs = Date.now();
+
+ // Read _heartbeat from the real cache file on disk
+ const heartbeat = readKey(
+ env.config.statusLine.cachePath,
+ "_heartbeat",
+ );
+
+ expect(heartbeat).not.toBeNull();
+ expect(heartbeat!.value).toBeGreaterThanOrEqual(beforeMs);
+ expect(heartbeat!.value).toBeLessThanOrEqual(afterMs);
+ expect(heartbeat!.ttl_seconds).toBe(999999);
+ });
+
+ it("dispatch(any event) writes _cwd to real cache file with correct value", () => {
+ dispatch("SessionStart", makePayload(), {
+ config: env.config,
+ projectDir: env.tmpDir,
+ });
+
+ // Read _cwd from the real cache file on disk
+ const cwd = readKey(
+ env.config.statusLine.cachePath,
+ "_cwd",
+ );
+
+ expect(cwd).not.toBeNull();
+ expect(cwd!.value).toBe(process.cwd());
+ expect(cwd!.ttl_seconds).toBe(999999);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task 3.2 — Analytics DB wiring (GH#9 — now wired)
+// ---------------------------------------------------------------------------
+
+describe("dispatcher-wiring: analytics DB wiring", () => {
+ let env: TestEnv;
+ const savedStateDir = process.env.HOOKWISE_STATE_DIR;
+
+ beforeEach(() => {
+ env = createTestEnv();
+ process.env.HOOKWISE_STATE_DIR = env.tmpDir;
+ });
+
+ afterEach(() => {
+ env.cleanup();
+ if (savedStateDir !== undefined) {
+ process.env.HOOKWISE_STATE_DIR = savedStateDir;
+ } else {
+ delete process.env.HOOKWISE_STATE_DIR;
+ }
+ });
+
+ it("dispatch(PostToolUse) → analytics DB has event row", () => {
+ // Start a session first (FK constraint)
+ dispatch("SessionStart", makePayload(), {
+ config: env.config,
+ projectDir: env.tmpDir,
+ });
+
+ dispatch("PostToolUse", makePayload({
+ tool_name: "Bash",
+ tool_input: { command: "ls -la" },
+ }), {
+ config: env.config,
+ projectDir: env.tmpDir,
+ });
+
+ const { events } = readAnalyticsDB(env.dbPath);
+ const toolEvents = events.filter((e: any) => e.event_type === "PostToolUse");
+ expect(toolEvents.length).toBeGreaterThanOrEqual(1);
+ expect((toolEvents[0] as any).tool_name).toBe("Bash");
+ expect((toolEvents[0] as any).session_id).toBe("integ-test-session");
+ });
+
+ it("dispatch(SessionStart) → session row created", () => {
+ dispatch("SessionStart", makePayload({
+ session_id: "analytics-session-1",
+ }), {
+ config: env.config,
+ projectDir: env.tmpDir,
+ });
+
+ const { sessions } = readAnalyticsDB(env.dbPath);
+ expect(sessions).toHaveLength(1);
+ expect((sessions[0] as any).id).toBe("analytics-session-1");
+ expect((sessions[0] as any).started_at).toBeDefined();
+ expect((sessions[0] as any).ended_at).toBeNull();
+ });
+
+ it("dispatch(SessionEnd) → session row updated", () => {
+ // Start then end a session
+ dispatch("SessionStart", makePayload({
+ session_id: "analytics-session-2",
+ }), {
+ config: env.config,
+ projectDir: env.tmpDir,
+ });
+
+ dispatch("SessionEnd", makePayload({
+ session_id: "analytics-session-2",
+ }), {
+ config: env.config,
+ projectDir: env.tmpDir,
+ });
+
+ const { sessions } = readAnalyticsDB(env.dbPath);
+ expect(sessions).toHaveLength(1);
+ expect((sessions[0] as any).id).toBe("analytics-session-2");
+ expect((sessions[0] as any).ended_at).not.toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task 3.3 — Fail-open and fault isolation
+// ---------------------------------------------------------------------------
+
+describe("dispatcher-wiring: fail-open and fault isolation", () => {
+ let env: TestEnv;
+ const savedStateDir = process.env.HOOKWISE_STATE_DIR;
+
+ beforeEach(() => {
+ env = createTestEnv();
+ process.env.HOOKWISE_STATE_DIR = env.tmpDir;
+ });
+
+ afterEach(() => {
+ env.cleanup();
+ if (savedStateDir !== undefined) {
+ process.env.HOOKWISE_STATE_DIR = savedStateDir;
+ } else {
+ delete process.env.HOOKWISE_STATE_DIR;
+ }
+ });
+
+ it("side-effect handler throws → exitCode still 0 (fail-open)", () => {
+ // Create a script that exits non-zero (simulating a throw)
+ const failScript = join(env.tmpDir, "fail-handler.sh");
+ writeFileSync(failScript, "#!/bin/bash\nexit 1\n", { mode: 0o755 });
+
+ // Config with a side-effect handler that fails
+ const config = {
+ ...env.config,
+ handlers: [
+ {
+ name: "failing-side-effect",
+ type: "script" as const,
+ events: ["PostToolUse" as const],
+ phase: "side_effect" as const,
+ command: `bash ${failScript}`,
+ },
+ ],
+ };
+
+ const result = dispatch("PostToolUse", makePayload(), {
+ config,
+ projectDir: env.tmpDir,
+ });
+
+ // Fail-open: exitCode must be 0 even when side-effect handler fails
+ expect(result.exitCode).toBe(0);
+ });
+
+ it("side-effect handler throws → other side-effects still execute (fault isolation)", () => {
+ // Create a script that exits non-zero (the "failing" handler)
+ const failScript = join(env.tmpDir, "fail-handler.sh");
+ writeFileSync(failScript, "#!/bin/bash\nexit 1\n", { mode: 0o755 });
+
+ // Create a script that writes a marker file (the "surviving" handler)
+ const markerPath = join(env.tmpDir, "side-effect-marker.txt");
+ const successScript = join(env.tmpDir, "success-handler.sh");
+ writeFileSync(
+ successScript,
+ `#!/bin/bash\necho "executed" > "${markerPath}"\n`,
+ { mode: 0o755 },
+ );
+
+ // Config with TWO side-effect handlers: first fails, second writes a marker
+ const config = {
+ ...env.config,
+ handlers: [
+ {
+ name: "failing-side-effect",
+ type: "script" as const,
+ events: ["PostToolUse" as const],
+ phase: "side_effect" as const,
+ command: `bash ${failScript}`,
+ },
+ {
+ name: "surviving-side-effect",
+ type: "script" as const,
+ events: ["PostToolUse" as const],
+ phase: "side_effect" as const,
+ command: `bash ${successScript}`,
+ },
+ ],
+ };
+
+ const result = dispatch("PostToolUse", makePayload(), {
+ config,
+ projectDir: env.tmpDir,
+ });
+
+ // Exit code is still 0 (fail-open)
+ expect(result.exitCode).toBe(0);
+
+ // The surviving handler must have executed and written the marker
+ expect(existsSync(markerPath)).toBe(true);
+ const marker = readFileSync(markerPath, "utf-8").trim();
+ expect(marker).toBe("executed");
+
+ // Heartbeat + CWD also written (they run before handlers)
+ const heartbeat = readKey(
+ config.statusLine.cachePath,
+ "_heartbeat",
+ );
+ expect(heartbeat).not.toBeNull();
+ });
+});
diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts
new file mode 100644
index 0000000..cf1bc63
--- /dev/null
+++ b/tests/integration/helpers.ts
@@ -0,0 +1,255 @@
+/**
+ * Shared integration test helpers for hookwise.
+ *
+ * Provides reusable setup/teardown and assertion utilities for
+ * pipeline-wiring, dispatcher-wiring, and status-line-flow tests.
+ *
+ * Key design decisions:
+ * - ARCH-1: No mocking of internal modules — imports real cache-bus, real AnalyticsDB
+ * - ARCH-2: Temp dir isolation — every test gets its own temp directory
+ * - All helpers are pure utilities with no global state
+ *
+ * Tasks: 1.1 (test env setup/teardown, config/payload builders)
+ * 1.2 (cache seeding, analytics reading, fresh/stale helpers)
+ */
+
+import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import yaml from "js-yaml";
+import Database from "better-sqlite3";
+import { mergeKey } from "../../src/core/feeds/cache-bus.js";
+import { getDefaultConfig } from "../../src/core/config.js";
+import type { HooksConfig, HookPayload, CacheEntry } from "../../src/core/types.js";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface TestEnv {
+ tmpDir: string;
+ cachePath: string;
+ dbPath: string;
+ configPath: string;
+ config: HooksConfig;
+ cleanup: () => void;
+}
+
+export interface CacheSeedEntry {
+ key: string;
+ data: Record;
+ ttlSeconds?: number; // default: 300
+}
+
+// ---------------------------------------------------------------------------
+// Task 1.1 — Test environment setup/teardown
+// ---------------------------------------------------------------------------
+
+/**
+ * Simple recursive merge: for each key in source, if both target[key] and
+ * source[key] are plain objects, recurse; otherwise source wins.
+ */
+function deepMerge(target: Record, source: Record): Record {
+ const result = { ...target };
+ for (const key of Object.keys(source)) {
+ if (
+ source[key] && typeof source[key] === "object" && !Array.isArray(source[key]) &&
+ target[key] && typeof target[key] === "object" && !Array.isArray(target[key])
+ ) {
+ result[key] = deepMerge(
+ target[key] as Record,
+ source[key] as Record,
+ );
+ } else {
+ result[key] = source[key];
+ }
+ }
+ return result;
+}
+
+/**
+ * Build a minimal valid HooksConfig with all features enabled and paths
+ * pointing to a given temp directory. Merges any caller overrides on top.
+ */
+export function makeIntegrationConfig(
+ overrides: Partial = {},
+ tmpDir?: string,
+): HooksConfig {
+ const base = getDefaultConfig();
+
+ // Enable all major features
+ base.analytics.enabled = true;
+ base.statusLine.enabled = true;
+ base.coaching.metacognition.enabled = true;
+ base.feeds.pulse.enabled = true;
+ base.feeds.project.enabled = true;
+ base.feeds.insights.enabled = true;
+
+ // Point paths to temp dir if provided
+ if (tmpDir) {
+ base.statusLine.cachePath = join(tmpDir, "status-line-cache.json");
+ base.analytics.dbPath = join(tmpDir, "analytics.db");
+ base.settings.stateDir = tmpDir;
+ }
+
+ // Apply caller overrides with deep merge to preserve nested defaults
+ return deepMerge(base, overrides) as HooksConfig;
+}
+
+/**
+ * Create an isolated test environment with a valid hookwise config,
+ * cache path, analytics DB path, and cleanup function.
+ *
+ * The temp directory is created via `mkdtempSync` per ARCH-2.
+ */
+export function createTestEnv(
+ configOverrides: Partial = {},
+): TestEnv {
+ const tmpDir = mkdtempSync(join(tmpdir(), "hookwise-integ-"));
+ const cachePath = join(tmpDir, "status-line-cache.json");
+ const dbPath = join(tmpDir, "analytics.db");
+ const configPath = join(tmpDir, "hookwise.yaml");
+
+ const config = makeIntegrationConfig(configOverrides, tmpDir);
+
+ // Write a real hookwise.yaml so loadConfig() can find it
+ writeFileSync(
+ configPath,
+ yaml.dump(
+ {
+ version: config.version,
+ analytics: { enabled: config.analytics.enabled, db_path: dbPath },
+ status_line: {
+ enabled: config.statusLine.enabled,
+ segments: config.statusLine.segments,
+ delimiter: config.statusLine.delimiter,
+ cache_path: cachePath,
+ },
+ coaching: {
+ metacognition: {
+ enabled: config.coaching.metacognition.enabled,
+ interval_seconds: config.coaching.metacognition.intervalSeconds,
+ },
+ },
+ feeds: {
+ pulse: {
+ enabled: config.feeds.pulse.enabled,
+ interval_seconds: config.feeds.pulse.intervalSeconds,
+ },
+ project: {
+ enabled: config.feeds.project.enabled,
+ interval_seconds: config.feeds.project.intervalSeconds,
+ },
+ insights: {
+ enabled: config.feeds.insights.enabled,
+ interval_seconds: config.feeds.insights.intervalSeconds,
+ },
+ },
+ handlers: [],
+ settings: {
+ log_level: config.settings.logLevel,
+ handler_timeout_seconds: config.settings.handlerTimeoutSeconds,
+ state_dir: tmpDir,
+ },
+ },
+ { indent: 2, noRefs: true },
+ ),
+ "utf-8",
+ );
+
+ const cleanup = () => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ };
+
+ return { tmpDir, cachePath, dbPath, configPath, config, cleanup };
+}
+
+/**
+ * Build a valid HookPayload with sensible defaults and optional overrides.
+ */
+export function makePayload(
+ overrides: Partial = {},
+): HookPayload {
+ return {
+ session_id: "integ-test-session",
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Task 1.2 — Cache seeding and analytics reading
+// ---------------------------------------------------------------------------
+
+/**
+ * Seed a real cache file with multiple entries via the actual cache-bus
+ * `mergeKey()` function. Each entry is written with proper freshness metadata.
+ *
+ * ARCH-1: Uses the real mergeKey implementation — no mocking.
+ */
+export function seedCache(
+ cachePath: string,
+ entries: CacheSeedEntry[],
+): void {
+ for (const entry of entries) {
+ mergeKey(cachePath, entry.key, entry.data, entry.ttlSeconds ?? 300);
+ }
+}
+
+/**
+ * Create a fresh CacheEntry (within TTL) for use in assertions and
+ * in-memory cache objects passed to segment renderers.
+ *
+ * @param data - Payload fields to include in the entry
+ * @param ttl - TTL in seconds (default: 300)
+ */
+export function freshEntry(
+ data: Record,
+ ttl = 300,
+): CacheEntry {
+ return {
+ updated_at: new Date().toISOString(),
+ ttl_seconds: ttl,
+ ...data,
+ };
+}
+
+/**
+ * Create a stale CacheEntry (TTL expired) for use in assertions.
+ * The `updated_at` timestamp is set far enough in the past that the
+ * entry's TTL of 60 seconds is guaranteed to have expired.
+ */
+export function staleEntry(
+ data: Record,
+): CacheEntry {
+ return {
+ updated_at: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
+ ttl_seconds: 60, // expired
+ ...data,
+ };
+}
+
+/**
+ * Open a real SQLite analytics database file and return the contents of
+ * the sessions, events, and authorship_ledger tables for assertion.
+ *
+ * The database connection is properly closed after reading.
+ *
+ * ARCH-1: Uses real better-sqlite3 — no mocking.
+ */
+export function readAnalyticsDB(dbPath: string): {
+ sessions: unknown[];
+ events: unknown[];
+ authorship: unknown[];
+} {
+ const db = new Database(dbPath, { readonly: true });
+ try {
+ const sessions = db.prepare("SELECT * FROM sessions").all();
+ const events = db.prepare("SELECT * FROM events ORDER BY timestamp").all();
+ const authorship = db
+ .prepare("SELECT * FROM authorship_ledger ORDER BY timestamp")
+ .all();
+ return { sessions, events, authorship };
+ } finally {
+ db.close();
+ }
+}
diff --git a/tests/integration/pipeline-wiring.test.ts b/tests/integration/pipeline-wiring.test.ts
new file mode 100644
index 0000000..966ecd6
--- /dev/null
+++ b/tests/integration/pipeline-wiring.test.ts
@@ -0,0 +1,626 @@
+/**
+ * Pipeline Wiring Integration Tests — Batch B (Tasks 2.1, 2.2, 2.3)
+ *
+ * Verifies that feed producer output flows through the real cache bus
+ * to segment renderers with no mocking of internal modules (ARCH-1).
+ *
+ * Each test uses its own temp directory (ARCH-2) and real file I/O.
+ * File is named pipeline-WIRING.test.ts to avoid collision with the
+ * existing pipeline.test.ts (ARCH-4).
+ */
+
+import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
+import { createTestEnv, seedCache } from "./helpers.js";
+import type { TestEnv } from "./helpers.js";
+import { mergeKey, readKey, readAll } from "../../src/core/feeds/cache-bus.js";
+import { BUILTIN_SEGMENTS } from "../../src/core/status-line/segments.js";
+import { createPulseProducer } from "../../src/core/feeds/producers/pulse.js";
+import type { PulseFeedConfig, CacheEntry } from "../../src/core/types.js";
+
+// ---------------------------------------------------------------------------
+// Shared state for afterEach cleanup
+// ---------------------------------------------------------------------------
+
+let env: TestEnv;
+
+// ---------------------------------------------------------------------------
+// Task 2.1 — Cache round-trip and basic pipeline wiring
+// ---------------------------------------------------------------------------
+
+describe("pipeline-wiring: cache round-trip and basic wiring", () => {
+ afterEach(() => {
+ env?.cleanup();
+ });
+
+ it("producer output → mergeKey → readKey returns data (cache round-trip)", () => {
+ env = createTestEnv();
+
+ // Simulate pulse producer output: write data through real cache bus
+ const producerOutput = {
+ value: "\u{1F7E2}",
+ elapsed_minutes: 5,
+ session_start: new Date().toISOString(),
+ };
+
+ mergeKey(env.cachePath, "pulse", producerOutput, 300);
+ const result = readKey(env.cachePath, "pulse");
+
+ expect(result).not.toBeNull();
+ expect(result!.value).toBe("\u{1F7E2}");
+ expect(result!.elapsed_minutes).toBe(5);
+ expect(result!.updated_at).toBeDefined();
+ expect(result!.ttl_seconds).toBe(300);
+ });
+
+ it("producer output → mergeKey → segment renders non-empty (full pipeline wiring)", () => {
+ env = createTestEnv();
+
+ // Write pulse data to the cache via real mergeKey
+ const pulseData = {
+ value: "\u{1F7E2}",
+ elapsed_minutes: 10,
+ session_start: new Date().toISOString(),
+ };
+ mergeKey(env.cachePath, "pulse", pulseData, 300);
+
+ // Read back the full cache and pass to the segment renderer
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["pulse"];
+ const output = segmentFn(cache, { builtin: "pulse" });
+
+ expect(output).toBeTruthy();
+ expect(output).toContain("\u{1F7E2}");
+ });
+
+ it("producer returns null → segment renders empty (fail-open)", () => {
+ env = createTestEnv();
+
+ // Do NOT write any pulse data — simulates a producer that returned null
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["pulse"];
+ const output = segmentFn(cache, { builtin: "pulse" });
+
+ expect(output).toBe("");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task 2.2 — TTL, atomic merge, orphan detection
+// ---------------------------------------------------------------------------
+
+describe("pipeline-wiring: TTL, atomic merge, and orphan detection", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-03T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ env?.cleanup();
+ });
+
+ it("cache TTL expired → readKey returns null → segment empty (ARCH-5)", () => {
+ env = createTestEnv();
+
+ // Write pulse data with a 60-second TTL
+ const pulseData = {
+ value: "\u{1F7E1}",
+ elapsed_minutes: 35,
+ session_start: "2026-03-03T11:25:00Z",
+ };
+ mergeKey(env.cachePath, "pulse", pulseData, 60);
+
+ // Verify it reads fine while fresh
+ const freshResult = readKey(env.cachePath, "pulse");
+ expect(freshResult).not.toBeNull();
+
+ // Advance time past the TTL
+ vi.setSystemTime(new Date("2026-03-03T12:02:00Z")); // 2 min later
+
+ // readKey should now return null (TTL expired)
+ const staleResult = readKey(env.cachePath, "pulse");
+ expect(staleResult).toBeNull();
+
+ // Segment should render empty for stale data
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["pulse"];
+ const output = segmentFn(cache, { builtin: "pulse" });
+ expect(output).toBe("");
+ });
+
+ it("multiple producers write different keys → keys don't overwrite (atomic merge)", () => {
+ vi.useRealTimers(); // This test doesn't need fake timers
+ env = createTestEnv();
+
+ // Producer 1: pulse
+ const pulseData = {
+ value: "\u{1F7E2}",
+ elapsed_minutes: 5,
+ session_start: new Date().toISOString(),
+ };
+ mergeKey(env.cachePath, "pulse", pulseData, 300);
+
+ // Producer 2: project (simulate project data)
+ const projectData = {
+ repo: "hookwise",
+ branch: "main",
+ last_commit_ts: Math.floor(Date.now() / 1000),
+ detached: false,
+ has_commits: true,
+ };
+ mergeKey(env.cachePath, "project", projectData, 300);
+
+ // Producer 3: news (simulate news data)
+ const newsData = {
+ stories: [{ title: "Test Story", score: 42, url: "https://example.com", id: 1 }],
+ current_index: 0,
+ current_story: { title: "Test Story", score: 42, url: "https://example.com", id: 1 },
+ last_rotation: new Date().toISOString(),
+ };
+ mergeKey(env.cachePath, "news", newsData, 300);
+
+ // All keys should be independently readable
+ const pulseResult = readKey(env.cachePath, "pulse");
+ const projectResult = readKey(env.cachePath, "project");
+ const newsResult = readKey(env.cachePath, "news");
+
+ expect(pulseResult).not.toBeNull();
+ expect(pulseResult!.value).toBe("\u{1F7E2}");
+
+ expect(projectResult).not.toBeNull();
+ expect(projectResult!.repo).toBe("hookwise");
+
+ expect(newsResult).not.toBeNull();
+ expect(newsResult!.current_story).toEqual(
+ expect.objectContaining({ title: "Test Story", score: 42 }),
+ );
+ });
+
+ it("each builtin producer has a corresponding segment (orphan detection)", () => {
+ vi.useRealTimers(); // No timer manipulation needed
+
+ // The builtin feed producers and their corresponding segment names.
+ // A producer is "orphaned" if its cache key has no segment that reads it.
+ //
+ // Mapping:
+ // pulse → "pulse" segment
+ // project → "project" segment
+ // calendar → "calendar" segment
+ // news → "news" segment
+ // insights → "insights_friction", "insights_pace", "insights_trend"
+ // practice → "practice", "practice_breadcrumb"
+ // weather → "weather" segment
+ // memories → "memories" segment
+ const PRODUCER_TO_SEGMENTS: Record = {
+ pulse: ["pulse"],
+ project: ["project"],
+ calendar: ["calendar"],
+ news: ["news"],
+ insights: ["insights_friction", "insights_pace", "insights_trend"],
+ practice: ["practice", "practice_breadcrumb"],
+ weather: ["weather"],
+ memories: ["memories"],
+ };
+
+ const allSegmentNames = Object.keys(BUILTIN_SEGMENTS);
+
+ for (const [producer, expectedSegments] of Object.entries(PRODUCER_TO_SEGMENTS)) {
+ for (const segName of expectedSegments) {
+ expect(
+ allSegmentNames,
+ `Producer "${producer}" expects segment "${segName}" but it is not in BUILTIN_SEGMENTS`,
+ ).toContain(segName);
+ }
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task 2.3 — Reusable pipeline flow helper
+// ---------------------------------------------------------------------------
+
+/**
+ * Reusable helper that verifies the full producer-to-segment pipeline:
+ * 1. Calls the producer function
+ * 2. Writes its output to the cache via mergeKey
+ * 3. Reads the cache back
+ * 4. Passes the cache to the named segment renderer
+ * 5. Asserts the rendered output contains the expected substring
+ *
+ * @param producerFn - Async function returning producer data or null
+ * @param segmentName - Name of the BUILTIN_SEGMENTS entry to render
+ * @param cachePath - Path to the cache file
+ * @param expectedSubstring - Substring the rendered segment output must contain
+ */
+async function testPipelineFlow(
+ producerFn: () => Promise | null>,
+ segmentName: string,
+ cachePath: string,
+ expectedSubstring: string,
+): Promise {
+ // Step 1: Call the producer
+ const data = await producerFn();
+ expect(data).not.toBeNull();
+
+ // Step 2: Write to cache via real mergeKey
+ // The cache key is the segment name for simple 1:1 mappings.
+ // For insights segments (insights_friction, etc.) the cache key is "insights".
+ const cacheKey = segmentName.startsWith("insights_") ? "insights" : segmentName;
+ mergeKey(cachePath, cacheKey, data!, 300);
+
+ // Step 3: Read back full cache
+ const cache = readAll(cachePath);
+
+ // Step 4: Render through the segment
+ const segmentFn = BUILTIN_SEGMENTS[segmentName];
+ expect(segmentFn, `Segment "${segmentName}" not found in BUILTIN_SEGMENTS`).toBeDefined();
+ const output = segmentFn(cache, { builtin: segmentName });
+
+ // Step 5: Assert output contains expected substring
+ expect(output).toContain(expectedSubstring);
+}
+
+describe("pipeline-wiring: reusable testPipelineFlow helper", () => {
+ afterEach(() => {
+ env?.cleanup();
+ });
+
+ it("testPipelineFlow works with pulse producer", async () => {
+ env = createTestEnv();
+
+ // Pulse producer requires a session entry in cache with startedAt
+ const sessionData = {
+ startedAt: new Date(Date.now() - 5 * 60_000).toISOString(), // 5 min ago
+ toolCalls: 3,
+ };
+ mergeKey(env.cachePath, "session", sessionData, 300);
+
+ // Create the pulse producer and run it through the pipeline helper
+ const pulseFeedConfig: PulseFeedConfig = {
+ enabled: true,
+ intervalSeconds: 30,
+ thresholds: { green: 0, yellow: 30, orange: 60, red: 120, skull: 180 },
+ };
+ const pulseProducer = createPulseProducer(env.cachePath, pulseFeedConfig);
+
+ await testPipelineFlow(
+ pulseProducer,
+ "pulse",
+ env.cachePath,
+ "\u{1F7E2}", // green circle (< 30 min)
+ );
+ });
+
+ it("testPipelineFlow works with insights data (insights_pace segment)", async () => {
+ env = createTestEnv();
+
+ // Simulate insights producer output — we directly create the data
+ // since the real insights producer reads from ~/.claude/usage-data/
+ // which we cannot easily populate in a temp dir.
+ const insightsData = {
+ total_sessions: 12,
+ total_messages: 240,
+ total_lines_added: 5000,
+ days_active: 10,
+ top_tools: [
+ { name: "Edit", count: 150 },
+ { name: "Read", count: 80 },
+ ],
+ peak_hour: 14,
+ friction_total: 3,
+ recent_session: {
+ friction_count: 0,
+ },
+ };
+
+ // Use a synthetic producer that returns the simulated data
+ const syntheticInsightsProducer = async () => insightsData;
+
+ await testPipelineFlow(
+ syntheticInsightsProducer,
+ "insights_pace",
+ env.cachePath,
+ "msgs/day", // insights_pace renders "X msgs/day"
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task: Practice producer → practice & practice_breadcrumb segments (GH#8)
+// ---------------------------------------------------------------------------
+
+describe("pipeline-wiring: practice producer to segment pipeline", () => {
+ afterEach(() => {
+ env?.cleanup();
+ });
+
+ it("practice producer output → mergeKey → practice segment renders today count", async () => {
+ env = createTestEnv();
+
+ // Simulate practice producer output: write practice data through real cache bus
+ const practiceData = {
+ todayTotal: 3,
+ dueReviews: 5,
+ last_at: new Date().toISOString(),
+ };
+
+ mergeKey(env.cachePath, "practice", practiceData, 300);
+
+ // Read back the full cache and pass to the segment renderer
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["practice"];
+ const output = segmentFn(cache, { builtin: "practice" });
+
+ expect(output).toBeTruthy();
+ expect(output).toContain("3 today");
+ });
+
+ it("practice producer output → mergeKey → practice_breadcrumb renders relative time", async () => {
+ env = createTestEnv();
+
+ // Write practice data with a last_at timestamp from 10 minutes ago
+ const tenMinAgo = new Date(Date.now() - 10 * 60_000).toISOString();
+ const practiceData = {
+ todayTotal: 2,
+ dueReviews: 4,
+ last_at: tenMinAgo,
+ };
+
+ mergeKey(env.cachePath, "practice", practiceData, 300);
+
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["practice_breadcrumb"];
+ const output = segmentFn(cache, { builtin: "practice_breadcrumb" });
+
+ expect(output).toBeTruthy();
+ expect(output).toContain("Last practice:");
+ expect(output).toContain("ago");
+ });
+
+ it("practice producer returns null → practice segment renders empty", () => {
+ env = createTestEnv();
+
+ // Do NOT write any practice data — simulates a producer that returned null
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["practice"];
+ const output = segmentFn(cache, { builtin: "practice" });
+
+ expect(output).toBe("");
+ });
+
+ it("testPipelineFlow works with practice data (practice segment)", async () => {
+ env = createTestEnv();
+
+ // Simulate practice producer output
+ const practiceData = {
+ todayTotal: 5,
+ dueReviews: 2,
+ last_at: new Date().toISOString(),
+ };
+
+ const syntheticPracticeProducer = async () =>
+ practiceData as unknown as Record;
+
+ await testPipelineFlow(
+ syntheticPracticeProducer,
+ "practice",
+ env.cachePath,
+ "5 today",
+ );
+ });
+
+ it("practice producer registered in orphan detection map", () => {
+ // Verify the practice producer has corresponding segments
+ const PRODUCER_TO_SEGMENTS: Record = {
+ practice: ["practice", "practice_breadcrumb"],
+ };
+
+ const allSegmentNames = Object.keys(BUILTIN_SEGMENTS);
+
+ for (const [producer, expectedSegments] of Object.entries(PRODUCER_TO_SEGMENTS)) {
+ for (const segName of expectedSegments) {
+ expect(
+ allSegmentNames,
+ `Producer "${producer}" expects segment "${segName}" but it is not in BUILTIN_SEGMENTS`,
+ ).toContain(segName);
+ }
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task: Weather producer → weather segment pipeline
+// ---------------------------------------------------------------------------
+
+describe("pipeline-wiring: weather producer to segment pipeline", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-03-03T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ env?.cleanup();
+ });
+
+ it("weather producer output → mergeKey → weather segment renders temp", async () => {
+ env = createTestEnv();
+
+ // Simulate weather producer output
+ const weatherData = {
+ temperature: 72,
+ weatherCode: 0,
+ windSpeed: 8.5,
+ description: "Clear",
+ emoji: "\u2600\uFE0F",
+ temperatureUnit: "fahrenheit",
+ };
+
+ mergeKey(env.cachePath, "weather", weatherData, 600);
+
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["weather"];
+ const output = segmentFn(cache, { builtin: "weather" });
+
+ expect(output).toBeTruthy();
+ expect(output).toContain("72");
+ expect(output).toContain("\u00B0F");
+ expect(output).toContain("\u2600\uFE0F");
+ });
+
+ it("weather producer output with high wind → segment shows wind indicator", async () => {
+ env = createTestEnv();
+
+ const weatherData = {
+ temperature: 58,
+ weatherCode: 61,
+ windSpeed: 25.3,
+ description: "Rain",
+ emoji: "\uD83C\uDF27\uFE0F",
+ temperatureUnit: "fahrenheit",
+ };
+
+ mergeKey(env.cachePath, "weather", weatherData, 600);
+
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["weather"];
+ const output = segmentFn(cache, { builtin: "weather" });
+
+ expect(output).toContain("\uD83D\uDCA8"); // wind emoji
+ });
+
+ it("weather producer returns null → weather segment renders fallback", () => {
+ env = createTestEnv();
+
+ // Do NOT write any weather data — simulates a producer that returned null
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["weather"];
+ const output = segmentFn(cache, { builtin: "weather" });
+
+ expect(output).toBe("\uD83C\uDF24\uFE0F --");
+ });
+
+ it("testPipelineFlow works with weather data (weather segment)", async () => {
+ env = createTestEnv();
+
+ const weatherData = {
+ temperature: 65,
+ weatherCode: 2,
+ windSpeed: 12,
+ description: "Cloudy",
+ emoji: "\u26C5",
+ temperatureUnit: "fahrenheit",
+ };
+
+ const syntheticWeatherProducer = async () =>
+ weatherData as unknown as Record;
+
+ await testPipelineFlow(
+ syntheticWeatherProducer,
+ "weather",
+ env.cachePath,
+ "65",
+ );
+ });
+
+ it("weather producer registered in orphan detection map", () => {
+ vi.useRealTimers();
+
+ const PRODUCER_TO_SEGMENTS: Record = {
+ weather: ["weather"],
+ };
+
+ const allSegmentNames = Object.keys(BUILTIN_SEGMENTS);
+
+ for (const [producer, expectedSegments] of Object.entries(PRODUCER_TO_SEGMENTS)) {
+ for (const segName of expectedSegments) {
+ expect(
+ allSegmentNames,
+ `Producer "${producer}" expects segment "${segName}" but it is not in BUILTIN_SEGMENTS`,
+ ).toContain(segName);
+ }
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task: Memories producer → memories segment pipeline (GH#80)
+// ---------------------------------------------------------------------------
+
+describe("pipeline-wiring: memories producer to segment pipeline", () => {
+ afterEach(() => {
+ env?.cleanup();
+ });
+
+ it("memories producer output → mergeKey → memories segment renders", async () => {
+ env = createTestEnv();
+
+ // Simulate memories producer output
+ const memoriesData = {
+ memories: [
+ { date: "2025-03-03", daysSince: 365, label: "1 year ago", toolCalls: 42, filesEdited: 15 },
+ { date: "2026-02-24", daysSince: 7, label: "1 week ago", toolCalls: 18, filesEdited: 6 },
+ ],
+ hasMemories: true,
+ };
+
+ mergeKey(env.cachePath, "memories", memoriesData, 3600);
+
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["memories"];
+ const output = segmentFn(cache, { builtin: "memories" });
+
+ expect(output).toBeTruthy();
+ expect(output).toContain("On this day");
+ expect(output).toContain("2 sessions");
+ });
+
+ it("memories producer returns null → memories segment renders empty", () => {
+ env = createTestEnv();
+
+ // Do NOT write any memories data — simulates a producer that returned null
+ const cache = readAll(env.cachePath);
+ const segmentFn = BUILTIN_SEGMENTS["memories"];
+ const output = segmentFn(cache, { builtin: "memories" });
+
+ expect(output).toBe("");
+ });
+
+ it("testPipelineFlow works with memories data", async () => {
+ env = createTestEnv();
+
+ const memoriesData = {
+ memories: [
+ { date: "2025-03-03", daysSince: 365, label: "1 year ago", toolCalls: 50, filesEdited: 20 },
+ ],
+ hasMemories: true,
+ };
+
+ const syntheticMemoriesProducer = async () =>
+ memoriesData as unknown as Record;
+
+ await testPipelineFlow(
+ syntheticMemoriesProducer,
+ "memories",
+ env.cachePath,
+ "On this day",
+ );
+ });
+
+ it("memories producer registered in orphan detection map", () => {
+ const PRODUCER_TO_SEGMENTS: Record = {
+ memories: ["memories"],
+ };
+
+ const allSegmentNames = Object.keys(BUILTIN_SEGMENTS);
+
+ for (const [producer, expectedSegments] of Object.entries(PRODUCER_TO_SEGMENTS)) {
+ for (const segName of expectedSegments) {
+ expect(
+ allSegmentNames,
+ `Producer "${producer}" expects segment "${segName}" but it is not in BUILTIN_SEGMENTS`,
+ ).toContain(segName);
+ }
+ }
+ });
+});
diff --git a/tests/integration/pipeline.test.ts b/tests/integration/pipeline.test.ts
index 1201751..724adef 100644
--- a/tests/integration/pipeline.test.ts
+++ b/tests/integration/pipeline.test.ts
@@ -164,12 +164,12 @@ describe("pipeline integration: SessionStart -> daemon -> feeds -> segments", ()
registerCustomFeeds(registry, config);
const allFeeds = registry.getAll();
- expect(allFeeds.length).toBeGreaterThanOrEqual(5); // pulse, project, calendar, news, insights
+ expect(allFeeds).toHaveLength(8); // pulse, project, calendar, news, insights, practice, weather, memories
const enabledFeeds = registry.getEnabled();
- // Default config: pulse, project, insights enabled; calendar and news disabled
+ // Default config: pulse, project, insights, practice enabled; calendar and news disabled
const enabledNames = enabledFeeds.map((f) => f.name).sort();
- expect(enabledNames).toEqual(["insights", "project", "pulse"]);
+ expect(enabledNames).toEqual(["insights", "practice", "project", "pulse"]);
// Step 3: Simulate a feed producing data and appearing in cache
// Create a mock cache as if the daemon wrote pulse data
@@ -394,7 +394,7 @@ describe("pipeline integration: custom feed registration", () => {
...getDefaultConfig().feeds,
custom: [
{
- name: "weather",
+ name: "local-weather",
command: "curl -s https://wttr.in?format=j1",
intervalSeconds: 120,
enabled: true,
@@ -410,14 +410,14 @@ describe("pipeline integration: custom feed registration", () => {
// Custom feed should be registered alongside built-ins
const all = registry.getAll();
- expect(all).toHaveLength(6); // 5 builtin + 1 custom
-
- const weather = registry.get("weather");
- expect(weather).toBeDefined();
- expect(weather!.name).toBe("weather");
- expect(weather!.intervalSeconds).toBe(120);
- expect(weather!.enabled).toBe(true);
- expect(typeof weather!.producer).toBe("function");
+ expect(all).toHaveLength(9); // 8 builtin + 1 custom
+
+ const localWeather = registry.get("local-weather");
+ expect(localWeather).toBeDefined();
+ expect(localWeather!.name).toBe("local-weather");
+ expect(localWeather!.intervalSeconds).toBe(120);
+ expect(localWeather!.enabled).toBe(true);
+ expect(typeof localWeather!.producer).toBe("function");
});
it("custom feed producer is a command-based producer", async () => {
@@ -435,7 +435,7 @@ describe("pipeline integration: custom feed registration", () => {
feeds: {
...getDefaultConfig().feeds,
custom: [
- { name: "weather", command: "get-weather", intervalSeconds: 120, enabled: true, timeoutSeconds: 10 },
+ { name: "local-weather", command: "get-weather", intervalSeconds: 120, enabled: true, timeoutSeconds: 10 },
{ name: "stocks", command: "get-stocks", intervalSeconds: 300, enabled: true, timeoutSeconds: 10 },
{ name: "ci-status", command: "get-ci", intervalSeconds: 60, enabled: false, timeoutSeconds: 5 },
],
@@ -446,10 +446,10 @@ describe("pipeline integration: custom feed registration", () => {
registerBuiltinFeeds(registry, config);
registerCustomFeeds(registry, config);
- expect(registry.getAll()).toHaveLength(8); // 5 builtin + 3 custom
- expect(registry.getEnabled()).toHaveLength(5); // pulse + project + insights + weather + stocks
+ expect(registry.getAll()).toHaveLength(11); // 8 builtin + 3 custom
+ expect(registry.getEnabled()).toHaveLength(6); // pulse + project + insights + practice + local-weather + stocks
- expect(registry.get("weather")!.enabled).toBe(true);
+ expect(registry.get("local-weather")!.enabled).toBe(true);
expect(registry.get("stocks")!.enabled).toBe(true);
expect(registry.get("ci-status")!.enabled).toBe(false);
});
@@ -647,6 +647,9 @@ describe("pipeline integration: config without daemon (NFR-4)", () => {
calendar: { ...getDefaultConfig().feeds.calendar, enabled: false },
news: { ...getDefaultConfig().feeds.news, enabled: false },
insights: { ...getDefaultConfig().feeds.insights, enabled: false },
+ practice: { ...getDefaultConfig().feeds.practice, enabled: false },
+ weather: { ...getDefaultConfig().feeds.weather, enabled: false },
+ memories: { ...getDefaultConfig().feeds.memories, enabled: false },
custom: [],
},
daemon: {
@@ -679,6 +682,9 @@ describe("pipeline integration: config without daemon (NFR-4)", () => {
calendar: { ...getDefaultConfig().feeds.calendar, enabled: false },
news: { ...getDefaultConfig().feeds.news, enabled: false },
insights: { ...getDefaultConfig().feeds.insights, enabled: false },
+ practice: { ...getDefaultConfig().feeds.practice, enabled: false },
+ weather: { ...getDefaultConfig().feeds.weather, enabled: false },
+ memories: { ...getDefaultConfig().feeds.memories, enabled: false },
custom: [],
},
});
@@ -687,7 +693,7 @@ describe("pipeline integration: config without daemon (NFR-4)", () => {
registerBuiltinFeeds(registry, config);
registerCustomFeeds(registry, config);
- expect(registry.getAll()).toHaveLength(5); // All registered but...
+ expect(registry.getAll()).toHaveLength(8); // All registered but...
expect(registry.getEnabled()).toHaveLength(0); // ...none enabled
});
diff --git a/tests/integration/status-line-flow.test.ts b/tests/integration/status-line-flow.test.ts
new file mode 100644
index 0000000..889e588
--- /dev/null
+++ b/tests/integration/status-line-flow.test.ts
@@ -0,0 +1,266 @@
+/**
+ * Integration tests for the status line rendering pipeline.
+ *
+ * Verifies that the renderer produces correct output from real cache state,
+ * covering single-tier render(), two-tier renderTwoTier(), fault isolation,
+ * and TTL-based stale-to-fresh transitions.
+ *
+ * Tasks: 4.1 (basic rendering from real cache state)
+ * 4.2 (two-tier rendering and segment fault isolation)
+ *
+ * Architecture constraints:
+ * - ARCH-1: No mocking of renderer, segments, or cache-bus
+ * - ARCH-2: Temp dir isolation via createTestEnv()
+ * - ARCH-5: vi.useFakeTimers() for TTL tests
+ * - ARCH-6: render(StatusLineConfig) reads cache internally;
+ * renderTwoTier(TwoTierConfig, cache) takes cache as argument
+ */
+
+import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
+import { render } from "../../src/core/status-line/renderer.js";
+import { renderTwoTier } from "../../src/core/status-line/two-tier.js";
+import type { TwoTierConfig } from "../../src/core/status-line/two-tier.js";
+import { mergeKey, readAll } from "../../src/core/feeds/cache-bus.js";
+import { BUILTIN_SEGMENTS } from "../../src/core/status-line/segments.js";
+import { createTestEnv, seedCache } from "./helpers.js";
+import type { TestEnv } from "./helpers.js";
+import type { StatusLineConfig, SegmentConfig } from "../../src/core/types.js";
+
+// ---------------------------------------------------------------------------
+// Shared setup
+// ---------------------------------------------------------------------------
+
+let env: TestEnv;
+
+afterEach(() => {
+ env?.cleanup();
+ vi.useRealTimers();
+});
+
+// ---------------------------------------------------------------------------
+// Task 4.1 — Basic rendering from real cache state
+// ---------------------------------------------------------------------------
+
+describe("status-line-flow: basic rendering from real cache state", () => {
+ it("fresh cache with mantra data -> render() output contains segment text", () => {
+ env = createTestEnv();
+
+ // Seed the cache file with mantra data via the real cache-bus write path.
+ // mantra is a simple segment that does NOT check isFresh(),
+ // so we just need the data present in the cache file.
+ seedCache(env.cachePath, [
+ { key: "mantra", data: { text: "Ship it" }, ttlSeconds: 300 },
+ ]);
+
+ // Build a StatusLineConfig that references the real cache file
+ const statusLineConfig: StatusLineConfig = {
+ enabled: true,
+ segments: [{ builtin: "mantra" }],
+ delimiter: " | ",
+ cachePath: env.cachePath,
+ };
+
+ // render() reads cache internally from config.cachePath (ARCH-6)
+ const output = render(statusLineConfig);
+
+ expect(output).toContain("Ship it");
+ });
+
+ it("empty/missing cache -> render() returns without crashing (fail-open)", () => {
+ env = createTestEnv();
+
+ // Do NOT seed any cache data — the file does not exist
+ const statusLineConfig: StatusLineConfig = {
+ enabled: true,
+ segments: [
+ { builtin: "pulse" },
+ { builtin: "project" },
+ { builtin: "mantra" },
+ ],
+ delimiter: " | ",
+ cachePath: env.cachePath,
+ };
+
+ // render() should not throw; it returns an empty string when no segments produce output
+ const output = render(statusLineConfig);
+ expect(output).toBe("");
+ });
+
+ it("cache written via mergeKey -> render(config) reads it (full data path)", () => {
+ env = createTestEnv();
+
+ // Use vi.useFakeTimers so isFresh() works deterministically for
+ // feed-type segments that check freshness (like pulse).
+ vi.useFakeTimers();
+ const now = new Date("2026-03-03T12:00:00Z").getTime();
+ vi.setSystemTime(now);
+
+ // Write cache data through the real mergeKey path
+ mergeKey(env.cachePath, "pulse", { value: "Active" }, 300);
+ mergeKey(env.cachePath, "mantra", { text: "Focus deeply" }, 300);
+
+ const statusLineConfig: StatusLineConfig = {
+ enabled: true,
+ segments: [{ builtin: "pulse" }, { builtin: "mantra" }],
+ delimiter: " | ",
+ cachePath: env.cachePath,
+ };
+
+ const output = render(statusLineConfig);
+
+ // Both segments should render — pulse checks isFresh() (just written, so fresh)
+ // and mantra reads the text field directly
+ expect(output).toContain("Active");
+ expect(output).toContain("Focus deeply");
+ // The delimiter joins the two segments
+ expect(output).toBe("Active | Focus deeply");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Task 4.2 — Two-tier rendering and segment fault isolation
+// ---------------------------------------------------------------------------
+
+describe("status-line-flow: two-tier rendering", () => {
+ it("fixed + rotating segments -> renderTwoTier() produces Line 1 + Line 2", () => {
+ env = createTestEnv();
+
+ vi.useFakeTimers();
+ const now = new Date("2026-03-03T12:00:00Z").getTime();
+ vi.setSystemTime(now);
+
+ // Build a cache object with data for both fixed and rotating segments.
+ // renderTwoTier takes cache as argument (ARCH-6).
+ const cache: Record = {
+ // Fixed segment: cost
+ cost: { sessionCostUsd: 1.5 },
+ // Fixed segment: context_bar (via _stdin)
+ _stdin: { context_window: { used_percentage: 42 } },
+ // Rotating segment: pulse (feed segment, checks isFresh)
+ pulse: {
+ updated_at: new Date(now - 2_000).toISOString(),
+ ttl_seconds: 300,
+ value: "Shipping",
+ },
+ };
+
+ const twoTierConfig: TwoTierConfig = {
+ fixedSegments: ["context_bar", "cost"],
+ rotatingSegments: ["pulse"],
+ delimiter: " | ",
+ };
+
+ const output = renderTwoTier(twoTierConfig, cache);
+
+ // Should have two lines separated by \n
+ const lines = output.split("\n");
+ expect(lines.length).toBe(2);
+
+ // Line 1 should contain fixed segments
+ // context_bar renders a progress bar with percentage
+ expect(lines[0]).toContain("42%");
+ // cost renders as $X.XX
+ expect(lines[0]).toContain("$1.50");
+
+ // Line 2 should contain the rotating segment
+ expect(lines[1]).toContain("Shipping");
+ });
+});
+
+describe("status-line-flow: segment fault isolation", () => {
+ it("one segment errors -> other segments still render", () => {
+ env = createTestEnv();
+
+ // Seed cache with data for one valid segment
+ seedCache(env.cachePath, [
+ { key: "mantra", data: { text: "Stay calm" }, ttlSeconds: 300 },
+ ]);
+
+ // Create a config where one segment references a non-existent builtin
+ // (which returns "" from renderSegment's `if (!renderer) return ""` path)
+ // and another references a valid segment.
+ const statusLineConfig: StatusLineConfig = {
+ enabled: true,
+ segments: [
+ { builtin: "nonexistent_segment_that_will_fail" },
+ { builtin: "mantra" },
+ ],
+ delimiter: " | ",
+ cachePath: env.cachePath,
+ };
+
+ const output = render(statusLineConfig);
+
+ // The valid segment should still render despite the invalid one
+ expect(output).toContain("Stay calm");
+ // Should not contain delimiter since only one segment produced output
+ expect(output).toBe("Stay calm");
+ });
+
+ it("renderTwoTier: one fixed segment fails -> others still render", () => {
+ env = createTestEnv();
+
+ vi.useFakeTimers();
+ const now = new Date("2026-03-03T12:00:00Z").getTime();
+ vi.setSystemTime(now);
+
+ const cache: Record = {
+ cost: { sessionCostUsd: 2.75 },
+ // No data for context_bar (_stdin missing) — it returns ""
+ // Also add a rotating segment
+ pulse: {
+ updated_at: new Date(now - 1_000).toISOString(),
+ ttl_seconds: 300,
+ value: "Rolling",
+ },
+ };
+
+ const twoTierConfig: TwoTierConfig = {
+ // context_bar will return "" because _stdin is missing
+ // mode_badge will return "" because builder_trap is missing
+ fixedSegments: ["context_bar", "mode_badge", "cost"],
+ rotatingSegments: ["pulse"],
+ delimiter: " | ",
+ };
+
+ const output = renderTwoTier(twoTierConfig, cache);
+ const lines = output.split("\n");
+
+ // Line 1 should still contain cost even though context_bar and mode_badge returned ""
+ expect(lines[0]).toContain("$2.75");
+
+ // Line 2 should contain pulse
+ expect(lines[1]).toContain("Rolling");
+ });
+});
+
+describe("status-line-flow: TTL / fresh-to-stale transition", () => {
+ it("fresh -> stale transition -> segment disappears from output", () => {
+ env = createTestEnv();
+
+ vi.useFakeTimers();
+ const now = new Date("2026-03-03T12:00:00Z").getTime();
+ vi.setSystemTime(now);
+
+ // Write pulse data with a short TTL (10 seconds) via real mergeKey
+ mergeKey(env.cachePath, "pulse", { value: "Alive" }, 10);
+
+ const statusLineConfig: StatusLineConfig = {
+ enabled: true,
+ segments: [{ builtin: "pulse" }],
+ delimiter: " | ",
+ cachePath: env.cachePath,
+ };
+
+ // At current time: pulse data is fresh (just written)
+ const freshOutput = render(statusLineConfig);
+ expect(freshOutput).toContain("Alive");
+
+ // Advance time past the TTL (10 seconds + buffer)
+ vi.setSystemTime(now + 15_000);
+
+ // Now pulse data is stale — isFresh() returns false — segment returns ""
+ const staleOutput = render(statusLineConfig);
+ expect(staleOutput).toBe("");
+ });
+});
diff --git a/tui/hookwise_tui/app.py b/tui/hookwise_tui/app.py
index 412e31c..a1eda24 100644
--- a/tui/hookwise_tui/app.py
+++ b/tui/hookwise_tui/app.py
@@ -1,7 +1,8 @@
-"""Main Hookwise TUI application — Textual app with 8 tabbed views."""
+"""Main Hookwise TUI application — Textual app with 8 tabbed views + weather background."""
from textual.app import App, ComposeResult
from textual.binding import Binding
+from textual.containers import Container
from textual.widgets import Footer, Header, TabbedContent, TabPane
from hookwise_tui.tabs.dashboard import DashboardTab
@@ -12,10 +13,21 @@
from hookwise_tui.tabs.insights import InsightsTab
from hookwise_tui.tabs.recipes import RecipesTab
from hookwise_tui.tabs.status import StatusTab
+from hookwise_tui.widgets.weather_background import WeatherBackground
+from hookwise_tui.widgets.weather_data import WeatherInfo, get_weather
+
+
+# Map weather conditions to background engine values
+_CONDITION_TO_WEATHER = {
+ "clear": "sun", "sun": "sun", "cloudy": "cloudy", "fog": "fog",
+ "drizzle": "drizzle", "rain": "rain", "heavy_rain": "heavy_rain",
+ "snow": "snow", "heavy_snow": "heavy_snow",
+ "thunderstorm": "thunderstorm",
+}
class HookwiseTUI(App):
- """Hookwise — Claude Code hooks dashboard."""
+ """Hookwise — Claude Code hooks dashboard with animated weather background."""
TITLE = "Hookwise"
SUB_TITLE = "Claude Code Hooks Dashboard"
@@ -31,39 +43,85 @@ class HookwiseTUI(App):
Binding("7", "switch_tab('recipes')", "Recipes", show=True),
Binding("8", "switch_tab('status')", "Status", show=True),
Binding("q", "quit", "Quit", show=True),
+ Binding("w", "cycle_weather", "Weather", show=False),
]
+ # Weather cycle order for the 'w' key
+ _WEATHER_CYCLE = [
+ "rain", "drizzle", "heavy_rain", "thunderstorm",
+ "snow", "heavy_snow", "sun", "fog", "cloudy",
+ ]
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._weather_index = 0
+
def compose(self) -> ComposeResult:
yield Header()
- with TabbedContent(
- "Dashboard",
- "Guards",
- "Coaching",
- "Analytics",
- "Feeds",
- "Insights",
- "Recipes",
- "Status",
- id="tabs",
- ):
- with TabPane("Dashboard", id="dashboard"):
- yield DashboardTab()
- with TabPane("Guards", id="guards"):
- yield GuardsTab()
- with TabPane("Coaching", id="coaching"):
- yield CoachingTab()
- with TabPane("Analytics", id="analytics"):
- yield AnalyticsTab()
- with TabPane("Feeds", id="feeds"):
- yield FeedsTab()
- with TabPane("Insights", id="insights"):
- yield InsightsTab()
- with TabPane("Recipes", id="recipes"):
- yield RecipesTab()
- with TabPane("Status", id="status"):
- yield StatusTab()
+
+ # Weather background layer — renders behind all content
+ # weather_info=None uses default; actual weather loaded in on_mount()
+ yield WeatherBackground(
+ weather_info=None,
+ id="weather-bg",
+ )
+
+ # Content layer — tabs sit on top of the weather background
+ with Container(id="content-layer"):
+ with TabbedContent(
+ "Dashboard",
+ "Guards",
+ "Coaching",
+ "Analytics",
+ "Feeds",
+ "Insights",
+ "Recipes",
+ "Status",
+ id="tabs",
+ ):
+ with TabPane("Dashboard", id="dashboard"):
+ yield DashboardTab()
+ with TabPane("Guards", id="guards"):
+ yield GuardsTab()
+ with TabPane("Coaching", id="coaching"):
+ yield CoachingTab()
+ with TabPane("Analytics", id="analytics"):
+ yield AnalyticsTab()
+ with TabPane("Feeds", id="feeds"):
+ yield FeedsTab()
+ with TabPane("Insights", id="insights"):
+ yield InsightsTab()
+ with TabPane("Recipes", id="recipes"):
+ yield RecipesTab()
+ with TabPane("Status", id="status"):
+ yield StatusTab()
yield Footer()
+ def on_mount(self) -> None:
+ # Load weather (may do a network fetch if cache is empty)
+ try:
+ weather_info = get_weather()
+ except Exception:
+ weather_info = WeatherInfo(
+ city="Local", condition="rain", code=61,
+ temp_c=9, temp_f=48, wind_speed=12,
+ )
+ bg = self.query_one("#weather-bg", WeatherBackground)
+ initial = _CONDITION_TO_WEATHER.get(
+ weather_info.condition, "rain"
+ )
+ bg.weather = initial
+
+ # Find the initial index in the cycle
+ if initial in self._WEATHER_CYCLE:
+ self._weather_index = self._WEATHER_CYCLE.index(initial)
+
def action_switch_tab(self, tab_id: str) -> None:
tabs = self.query_one(TabbedContent)
tabs.active = tab_id
+
+ def action_cycle_weather(self) -> None:
+ """Cycle through weather conditions with the 'w' key."""
+ self._weather_index = (self._weather_index + 1) % len(self._WEATHER_CYCLE)
+ new_weather = self._WEATHER_CYCLE[self._weather_index]
+ self.query_one("#weather-bg", WeatherBackground).weather = new_weather
diff --git a/tui/hookwise_tui/app.tcss b/tui/hookwise_tui/app.tcss
index 86a0d18..91bb963 100644
--- a/tui/hookwise_tui/app.tcss
+++ b/tui/hookwise_tui/app.tcss
@@ -1,7 +1,8 @@
-/* Hookwise TUI — Dark theme with vibrant accents */
+/* Hookwise TUI — Dark theme with vibrant accents + weather background */
Screen {
- background: $surface;
+ layers: below content;
+ background: #0a1520;
}
Header {
@@ -13,6 +14,22 @@ Footer {
background: $primary;
}
+/* Weather background — renders behind all content */
+#weather-bg {
+ layer: below;
+ dock: top;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+}
+
+/* Content layer — sits on top of weather background */
+#content-layer {
+ layer: content;
+ width: 100%;
+ height: 100%;
+}
+
/* Tab styling */
TabbedContent {
height: 1fr;
@@ -20,6 +37,7 @@ TabbedContent {
TabPane {
padding: 1 2;
+ background: rgba(10, 21, 32, 0.75);
}
/* Feature cards */
@@ -28,7 +46,7 @@ FeatureCard {
margin: 0 0 1 0;
padding: 1 2;
border: round $accent;
- background: $surface-darken-1;
+ background: rgba(10, 21, 32, 0.88);
}
FeatureCard.enabled {
@@ -97,7 +115,7 @@ DataTable > .datatable--header {
padding: 1 2;
margin: 0 1 1 0;
border: round $primary;
- background: $surface-darken-1;
+ background: rgba(10, 21, 32, 0.88);
min-width: 20;
}
@@ -167,7 +185,7 @@ DataTable > .datatable--header {
border: round $accent;
padding: 1 2;
margin: 1 0;
- background: $surface-darken-1;
+ background: rgba(10, 21, 32, 0.88);
}
.summary-title {
@@ -190,7 +208,7 @@ DataTable > .datatable--header {
padding: 1 2;
margin: 1 0;
border: heavy $accent;
- background: $surface-darken-2;
+ background: rgba(10, 21, 32, 0.92);
text-style: bold;
}
@@ -224,7 +242,7 @@ DataTable > .datatable--header {
border: round $warning;
padding: 1 2;
margin: 1 0;
- background: $surface-darken-1;
+ background: rgba(10, 21, 32, 0.88);
}
/* Refresh button */
diff --git a/tui/hookwise_tui/data.py b/tui/hookwise_tui/data.py
index eb6b561..8630373 100644
--- a/tui/hookwise_tui/data.py
+++ b/tui/hookwise_tui/data.py
@@ -322,6 +322,9 @@ def read_feed_health(
("calendar", feeds_config.get("calendar", {})),
("news", feeds_config.get("news", {})),
("insights", feeds_config.get("insights", {})),
+ ("practice", feeds_config.get("practice", {})),
+ ("weather", feeds_config.get("weather", {})),
+ ("memories", feeds_config.get("memories", {})),
]
results = []
diff --git a/tui/hookwise_tui/widgets/__init__.py b/tui/hookwise_tui/widgets/__init__.py
index e69de29..38140a1 100644
--- a/tui/hookwise_tui/widgets/__init__.py
+++ b/tui/hookwise_tui/widgets/__init__.py
@@ -0,0 +1,16 @@
+"""Hookwise TUI widgets."""
+
+from hookwise_tui.widgets.feature_card import FeatureCard
+from hookwise_tui.widgets.feed_health import FeedHealthWidget
+from hookwise_tui.widgets.sparkline import SparklineWidget
+from hookwise_tui.widgets.weather_background import WeatherBackground
+from hookwise_tui.widgets.weather_data import WeatherInfo, get_weather
+
+__all__ = [
+ "FeatureCard",
+ "FeedHealthWidget",
+ "SparklineWidget",
+ "WeatherBackground",
+ "WeatherInfo",
+ "get_weather",
+]
diff --git a/tui/hookwise_tui/widgets/weather_background.py b/tui/hookwise_tui/widgets/weather_background.py
new file mode 100644
index 0000000..6efd211
--- /dev/null
+++ b/tui/hookwise_tui/widgets/weather_background.py
@@ -0,0 +1,473 @@
+"""Weather background widget — animated particle engine for the TUI.
+
+Ported from tui/prototypes/rain_weather.py with the following adaptations:
+- Reads weather from hookwise cache (weather feed producer) instead of live fetch
+- Falls back to Open-Meteo live fetch if no cache data available
+- Integrates as a background layer behind all tab content
+- Uses Textual's timer system for non-blocking animation
+"""
+
+from __future__ import annotations
+
+import math
+import random
+
+from rich.text import Text as RichText
+from textual.reactive import reactive
+from textual.widget import Widget
+
+from hookwise_tui.widgets.weather_data import WeatherInfo, get_weather
+
+
+# --- Particle Types ---
+
+
+class _Particle:
+ """A weather particle with physics."""
+
+ __slots__ = ("x", "y", "char", "speed", "drift", "opacity", "layer")
+
+ def __init__(
+ self,
+ x: float,
+ y: float,
+ char: str,
+ speed: float,
+ drift: float = 0.0,
+ opacity: int = 2,
+ layer: int = 0,
+ ):
+ self.x = x
+ self.y = y
+ self.char = char
+ self.speed = speed
+ self.drift = drift
+ self.opacity = opacity # 0=dim, 1=normal, 2=bright
+ self.layer = layer # 0=far, 1=mid, 2=near
+
+
+class _Splash:
+ """A splash effect when rain hits the ground."""
+
+ __slots__ = ("x", "y", "life", "max_life")
+
+ def __init__(self, x: float, y: float):
+ self.x = x
+ self.y = y
+ self.life = 0
+ self.max_life = random.randint(3, 6)
+
+
+class _Cloud:
+ """A drifting cloud formation."""
+
+ __slots__ = ("x", "y", "shape", "speed", "width")
+
+ SHAPES = [
+ [" .-~~~-.", " / \\", "( ~cloud~ )", " \\_______/"],
+ [" .--.", " / \\", "( ~~ )", " \\__/"],
+ [" .-~~-.", " / \\", " ( ~~~~ )", " \\______/"],
+ [" .~.", "/ \\", "( ~ )", " '-'"],
+ ]
+
+ def __init__(self, x: float, y: float, speed: float, shape_idx: int = 0):
+ self.x = x
+ self.y = y
+ self.shape = self.SHAPES[shape_idx % len(self.SHAPES)]
+ self.speed = speed
+ self.width = max(len(line) for line in self.shape)
+
+
+# --- Weather Background Engine ---
+
+
+class WeatherBackground(Widget):
+ """Multi-layer animated weather with particles, clouds, and effects.
+
+ Renders as a full-screen background layer behind all content.
+ Weather condition drives particle type, density, and behavior.
+ """
+
+ DEFAULT_CSS = """
+ WeatherBackground {
+ width: 100%;
+ height: 100%;
+ }
+ """
+
+ weather: reactive[str] = reactive("rain")
+ frame: reactive[int] = reactive(0)
+
+ def __init__(self, weather_info: WeatherInfo | None = None, **kwargs):
+ super().__init__(**kwargs)
+ self.particles: list[_Particle] = []
+ self.splashes: list[_Splash] = []
+ self.clouds: list[_Cloud] = []
+ self._width = 80
+ self._height = 24
+ self._wind = 0.0
+ self._wind_target = 0.0
+ self._gust_timer = 0
+ self._lightning_flash = 0
+ self._lightning_timer = 0
+ self._time_offset = 0.0
+ self._weather_info = weather_info
+
+ def on_mount(self) -> None:
+ self._width = self.size.width or 80
+ self._height = self.size.height or 24
+ self._spawn_all()
+ self.set_interval(0.06, self._tick)
+
+ def on_resize(self) -> None:
+ self._width = self.size.width or 80
+ self._height = self.size.height or 24
+
+ # --- Spawning ---
+
+ def _spawn_all(self) -> None:
+ self.particles = []
+ self.splashes = []
+ self.clouds = []
+ self._spawn_clouds()
+ self._spawn_particles()
+
+ def _spawn_clouds(self) -> None:
+ self.clouds = []
+ if self.weather in ("clear", "sun"):
+ count = 1
+ elif self.weather == "fog":
+ count = 6
+ else:
+ count = random.randint(2, 4)
+
+ for _ in range(count):
+ self.clouds.append(
+ _Cloud(
+ x=random.uniform(-20, self._width + 20),
+ y=random.randint(0, 3),
+ speed=random.uniform(0.02, 0.08),
+ shape_idx=random.randint(0, 3),
+ )
+ )
+
+ def _spawn_particles(self) -> None:
+ self.particles = []
+ w, h = self._width, self._height
+
+ if self.weather in ("rain", "drizzle"):
+ density = w if self.weather == "rain" else w // 3
+ chars_near = ["|", "\u2502", "\u2503"]
+ chars_mid = ["\u254e", "\u250a", "/"]
+ chars_far = [".", ",", "'"]
+ for _ in range(density):
+ layer = random.choices([0, 1, 2], weights=[3, 4, 3])[0]
+ if layer == 2:
+ chars, spd, opac = chars_near, random.uniform(1.0, 1.8), 2
+ elif layer == 1:
+ chars, spd, opac = chars_mid, random.uniform(0.6, 1.0), 1
+ else:
+ chars, spd, opac = chars_far, random.uniform(0.3, 0.5), 0
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(-h, h),
+ char=random.choice(chars),
+ speed=spd,
+ drift=random.uniform(-0.1, 0.05),
+ opacity=opac,
+ layer=layer,
+ )
+ )
+
+ elif self.weather == "heavy_rain":
+ for _ in range(int(w * 1.5)):
+ layer = random.choices([0, 1, 2], weights=[2, 3, 5])[0]
+ chars = (
+ ["|", "\u2502", "\u2503", "\u2551"]
+ if layer >= 1
+ else ["\u254e", "/", "."]
+ )
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(-h, h),
+ char=random.choice(chars),
+ speed=random.uniform(1.2, 2.5),
+ drift=random.uniform(-0.3, -0.05),
+ opacity=min(layer + 1, 2),
+ layer=layer,
+ )
+ )
+
+ elif self.weather == "thunderstorm":
+ for _ in range(int(w * 1.8)):
+ layer = random.choices([0, 1, 2], weights=[2, 3, 5])[0]
+ chars = (
+ ["|", "\u2502", "\u2503", "\u2551"]
+ if layer >= 1
+ else ["\u254e", "/"]
+ )
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(-h, h),
+ char=random.choice(chars),
+ speed=random.uniform(1.5, 3.0),
+ drift=random.uniform(-0.5, -0.1),
+ opacity=min(layer + 1, 2),
+ layer=layer,
+ )
+ )
+
+ elif self.weather in ("snow", "heavy_snow"):
+ density = w // 2 if self.weather == "snow" else w
+ chars = ["*", "\u00b7", "\u2022", "\u2218", "\u25e6",
+ "\u2726", "\u2744", "\u2746", "\u273b", "\u2042"]
+ for _ in range(density):
+ layer = random.choices([0, 1, 2], weights=[4, 4, 2])[0]
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(-h, h),
+ char=random.choice(chars),
+ speed=random.uniform(0.08, 0.25) * (1 + layer * 0.3),
+ drift=0.0,
+ opacity=layer,
+ layer=layer,
+ )
+ )
+
+ elif self.weather in ("clear", "sun"):
+ # Twinkling stars
+ chars = ["\u2726", "\u00b7", "\u02da", "\u00b0", "\u207a", "\u2217"]
+ for _ in range(w // 6):
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(0, h),
+ char=random.choice(chars),
+ speed=0.0,
+ drift=0.0,
+ opacity=random.randint(0, 2),
+ layer=0,
+ )
+ )
+ # Floating dust motes
+ for _ in range(w // 8):
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(0, h),
+ char=random.choice(["\u00b7", "\u2218", "'"]),
+ speed=random.uniform(0.02, 0.06),
+ drift=random.uniform(0.01, 0.04),
+ opacity=0,
+ layer=1,
+ )
+ )
+
+ elif self.weather == "fog":
+ fog_chars = ["\u2591", "\u2592", "\u00b7", "\u2218", " "]
+ for _ in range(w * 2):
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(0, h),
+ char=random.choice(fog_chars),
+ speed=0.0,
+ drift=random.uniform(0.01, 0.05),
+ opacity=random.randint(0, 1),
+ layer=0,
+ )
+ )
+
+ elif self.weather == "cloudy":
+ for _ in range(w // 6):
+ self.particles.append(
+ _Particle(
+ x=random.uniform(0, w),
+ y=random.uniform(0, h),
+ char=random.choice(["\u00b7", "\u2218", "'"]),
+ speed=random.uniform(0.01, 0.04),
+ drift=random.uniform(-0.02, 0.02),
+ opacity=0,
+ layer=0,
+ )
+ )
+
+ # --- Physics tick ---
+
+ def _tick(self) -> None:
+ self._time_offset += 0.06
+ w, h = self._width, self._height
+
+ # Wind gusts
+ self._gust_timer += 1
+ if self._gust_timer > random.randint(60, 180):
+ self._gust_timer = 0
+ if self.weather in ("rain", "heavy_rain", "drizzle", "thunderstorm"):
+ self._wind_target = random.uniform(-0.6, 0.1)
+ elif self.weather in ("snow", "heavy_snow"):
+ self._wind_target = random.uniform(-0.3, 0.3)
+ else:
+ self._wind_target = random.uniform(-0.05, 0.05)
+
+ # Lerp wind toward target
+ self._wind += (self._wind_target - self._wind) * 0.03
+ self._wind_target *= 0.998
+
+ # Lightning timer (thunderstorm only)
+ if self.weather == "thunderstorm":
+ self._lightning_timer += 1
+ if self._lightning_timer > random.randint(80, 250):
+ self._lightning_timer = 0
+ self._lightning_flash = random.randint(2, 5)
+ if self._lightning_flash > 0:
+ self._lightning_flash -= 1
+
+ # Update clouds
+ for cloud in self.clouds:
+ cloud.x += cloud.speed + self._wind * 0.3
+ if cloud.x > w + 30:
+ cloud.x = -cloud.width - random.randint(5, 30)
+ cloud.y = random.randint(0, 3)
+ elif cloud.x < -cloud.width - 30:
+ cloud.x = w + random.randint(5, 30)
+
+ # Update particles
+ for p in self.particles:
+ if self.weather in ("snow", "heavy_snow"):
+ sway = (
+ math.sin(self._time_offset * 1.5 + p.x * 0.1 + p.layer) * 0.15
+ )
+ p.x += sway + self._wind * (0.5 + p.layer * 0.2)
+ p.y += p.speed
+ elif self.weather in ("clear", "sun") and p.layer == 0:
+ # Twinkling
+ if random.random() < 0.03:
+ p.opacity = (p.opacity + 1) % 3
+ continue
+ elif self.weather == "fog":
+ p.x += p.drift + self._wind * 0.2
+ p.y += math.sin(self._time_offset + p.x * 0.05) * 0.02
+ elif self.weather in ("clear", "sun") and p.layer == 1:
+ # Dust motes — gentle drift (must be before the else branch)
+ p.y += p.speed
+ p.x += p.drift + math.sin(
+ self._time_offset * 0.5 + p.y * 0.2
+ ) * 0.03
+ else:
+ # Rain/drizzle/thunderstorm
+ p.y += p.speed
+ p.x += p.drift + self._wind * (0.3 + p.layer * 0.2)
+
+ # Respawn at top when off bottom
+ if p.y > h:
+ if (
+ self.weather in ("rain", "heavy_rain", "thunderstorm")
+ and p.layer >= 1
+ ):
+ if random.random() < 0.3:
+ self.splashes.append(_Splash(p.x, h - 1))
+ p.y = random.uniform(-4, -1)
+ p.x = random.uniform(0, w)
+
+ # Wrap horizontally
+ if p.x < 0:
+ p.x += w
+ elif p.x >= w:
+ p.x -= w
+
+ # Update splashes
+ self.splashes = [s for s in self.splashes if s.life < s.max_life]
+ for s in self.splashes:
+ s.life += 1
+
+ self.frame = self.frame + 1
+
+ # --- Reactive watchers ---
+
+ def watch_weather(self, new_weather: str) -> None:
+ self._wind = 0.0
+ self._wind_target = 0.0
+ self._lightning_flash = 0
+ self._spawn_all()
+
+ # --- Rendering ---
+
+ def render(self) -> RichText:
+ w, h = self._width, self._height
+ if w <= 0 or h <= 0:
+ return RichText("")
+
+ # Build grid: (char, style)
+ grid = [[(" ", "")] * w for _ in range(h)]
+
+ # Layer 0: Clouds
+ for cloud in self.clouds:
+ cx = int(cloud.x)
+ for row_idx, line in enumerate(cloud.shape):
+ ry = int(cloud.y) + row_idx
+ if 0 <= ry < h:
+ for col_idx, ch in enumerate(line):
+ rx = cx + col_idx
+ if 0 <= rx < w and ch != " ":
+ grid[ry][rx] = (ch, "dim white")
+
+ # Layer 1: Particles
+ for p in self.particles:
+ px, py = int(p.x), int(p.y)
+ if 0 <= px < w and 0 <= py < h:
+ if self.weather in ("rain", "heavy_rain", "drizzle", "thunderstorm"):
+ styles = ["#1a3a5c", "#4488bb", "bold #66bbff"]
+ elif self.weather in ("snow", "heavy_snow"):
+ styles = ["#555577", "#9999bb", "bold #ddeeff"]
+ elif self.weather in ("clear", "sun"):
+ styles = ["#554400", "#aa8800", "bold #ffdd44"]
+ elif self.weather == "fog":
+ styles = ["#333344", "#555566", "#777788"]
+ else:
+ styles = ["dim", "", "bold"]
+ style = styles[min(p.opacity, len(styles) - 1)]
+ grid[py][px] = (p.char, style)
+
+ # Layer 2: Splash effects
+ splash_frames = [
+ ["\u00b7", "\u2218", "\u00b7"],
+ ["~", "\u2218", "~"],
+ ["-", "\u00b7", "-"],
+ [".", " ", "."],
+ ]
+ for s in self.splashes:
+ sx, sy = int(s.x), int(s.y)
+ if 0 <= sy < h:
+ frame_idx = min(s.life, len(splash_frames) - 1)
+ splash = splash_frames[frame_idx]
+ for i, ch in enumerate(splash):
+ rx = sx - 1 + i
+ if 0 <= rx < w and ch != " ":
+ grid[sy][rx] = (ch, "#4488bb")
+
+ # Build Rich text with per-character styling
+ text = RichText()
+ for row_idx in range(h):
+ for col_idx in range(w):
+ char, style = grid[row_idx][col_idx]
+
+ # Lightning flash override
+ if self._lightning_flash > 0 and char != " ":
+ if self._lightning_flash >= 3:
+ style = "bold white on #333355"
+ else:
+ style = "bold #aaaacc"
+
+ if style:
+ text.append(char, style=style)
+ else:
+ text.append(char)
+
+ if row_idx < h - 1:
+ text.append("\n")
+
+ return text
diff --git a/tui/hookwise_tui/widgets/weather_data.py b/tui/hookwise_tui/widgets/weather_data.py
new file mode 100644
index 0000000..6dc02a1
--- /dev/null
+++ b/tui/hookwise_tui/widgets/weather_data.py
@@ -0,0 +1,210 @@
+"""Weather data bridge — reads weather from hookwise cache or fetches live."""
+
+from __future__ import annotations
+
+import json
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from hookwise_tui.data import read_cache
+
+
+# --- Weather code mapping (WMO standard) ---
+
+WEATHER_CODE_MAP: dict[int, str] = {
+ 0: "clear", 1: "clear", 2: "cloudy", 3: "cloudy",
+ 45: "fog", 48: "fog",
+ 51: "drizzle", 53: "drizzle", 55: "drizzle",
+ 61: "rain", 63: "rain", 65: "heavy_rain",
+ 66: "rain", 67: "heavy_rain",
+ 71: "snow", 73: "snow", 75: "heavy_snow",
+ 77: "snow",
+ 80: "rain", 81: "rain", 82: "heavy_rain",
+ 85: "snow", 86: "heavy_snow",
+ 95: "thunderstorm", 96: "thunderstorm", 99: "thunderstorm",
+}
+
+# All valid weather conditions the background engine supports
+VALID_CONDITIONS = frozenset({
+ "clear", "sun", "cloudy", "fog", "drizzle",
+ "rain", "heavy_rain", "snow", "heavy_snow", "thunderstorm",
+})
+
+
+def _safe_float(val: Any, default: float = 0.0) -> float:
+ """Safely convert a value to float, returning *default* on failure."""
+ try:
+ return float(val) if val is not None else default
+ except (TypeError, ValueError):
+ return default
+
+
+def _safe_int(val: Any, default: int = 0) -> int:
+ """Safely convert a value to int, returning *default* on failure."""
+ try:
+ return int(float(val)) if val is not None else default
+ except (TypeError, ValueError):
+ return default
+
+
+@dataclass
+class WeatherInfo:
+ """Parsed weather information for the TUI."""
+
+ city: str
+ condition: str
+ code: int
+ temp_c: float
+ temp_f: float
+ wind_speed: float
+
+ @property
+ def display_condition(self) -> str:
+ """Human-readable condition with emoji."""
+ emoji_map = {
+ "clear": "Clear", "sun": "Clear",
+ "cloudy": "Cloudy", "fog": "Fog",
+ "drizzle": "Drizzle", "rain": "Rain",
+ "heavy_rain": "Heavy Rain",
+ "snow": "Snow", "heavy_snow": "Heavy Snow",
+ "thunderstorm": "Thunderstorm",
+ }
+ return emoji_map.get(self.condition, self.condition.replace("_", " ").title())
+
+ @property
+ def condition_emoji(self) -> str:
+ """Emoji for the weather condition."""
+ emoji_map = {
+ "clear": "\u2600\ufe0f", "sun": "\u2600\ufe0f",
+ "cloudy": "\u2601\ufe0f", "fog": "\ud83c\udf2b\ufe0f",
+ "drizzle": "\ud83c\udf26\ufe0f", "rain": "\ud83c\udf27\ufe0f",
+ "heavy_rain": "\ud83c\udf27\ufe0f",
+ "snow": "\ud83c\udf28\ufe0f", "heavy_snow": "\u2744\ufe0f",
+ "thunderstorm": "\u26c8\ufe0f",
+ }
+ return emoji_map.get(self.condition, "\ud83c\udf24\ufe0f")
+
+
+def read_weather_from_cache(
+ cache: dict[str, Any] | None = None,
+) -> WeatherInfo | None:
+ """Read weather data from the hookwise status-line cache.
+
+ The weather producer (when enabled) writes weather data into the cache bus
+ under the 'weather' key. This function extracts and parses that data.
+
+ Returns None if no weather data is available in the cache.
+ """
+ if cache is None:
+ cache = read_cache()
+
+ weather_entry = cache.get("weather")
+ if not isinstance(weather_entry, dict) or not weather_entry:
+ return None
+
+ # The weather producer writes camelCase fields:
+ # temperature, weatherCode, windSpeed, description, emoji, temperatureUnit
+ # The live fallback and legacy caches may write snake_case:
+ # city, condition, code, temp_c, temp_f, wind_speed
+ # Support both schemas for robustness.
+
+ code = weather_entry.get("weatherCode", weather_entry.get("code", 0))
+ wind_speed = weather_entry.get("windSpeed", weather_entry.get("wind_speed", 0))
+ temperature = weather_entry.get("temperature", 0)
+ temp_unit = weather_entry.get("temperatureUnit", "")
+ description = weather_entry.get("description", "")
+ city = weather_entry.get("city", "")
+
+ # Derive condition from description or WMO code
+ condition = weather_entry.get("condition", "")
+ if not condition and description:
+ condition = description.lower().replace(" ", "_")
+ if condition not in VALID_CONDITIONS:
+ condition = WEATHER_CODE_MAP.get(_safe_int(code), "cloudy")
+
+ # If no useful data at all, return None
+ if not condition and not temperature and not code:
+ return None
+
+ # Compute temp_c/temp_f from the single temperature + unit, or from legacy fields
+ if temp_unit == "celsius":
+ temp_c = _safe_float(temperature) if temperature else _safe_float(weather_entry.get("temp_c"))
+ temp_f = round(temp_c * 9 / 5 + 32)
+ elif temp_unit == "fahrenheit" or temperature:
+ temp_f = _safe_float(temperature) if temperature else _safe_float(weather_entry.get("temp_f"))
+ temp_c = round((temp_f - 32) * 5 / 9)
+ else:
+ temp_c = _safe_float(weather_entry.get("temp_c"))
+ temp_f = _safe_float(weather_entry.get("temp_f"))
+
+ return WeatherInfo(
+ city=str(city) if city else "Local",
+ condition=condition,
+ code=_safe_int(code),
+ temp_c=temp_c,
+ temp_f=temp_f,
+ wind_speed=_safe_float(wind_speed),
+ )
+
+
+def fetch_weather_live() -> WeatherInfo:
+ """Fetch real weather via IP geolocation + Open-Meteo. No API key needed.
+
+ This is a fallback when no weather data is in the cache.
+ Uses the same approach as the prototype.
+ """
+ try:
+ req = urllib.request.Request(
+ "https://ipinfo.io/json",
+ headers={"User-Agent": "hookwise-tui"},
+ )
+ with urllib.request.urlopen(req, timeout=3) as resp:
+ loc = json.loads(resp.read())
+ city = loc.get("city", "Unknown")
+ lat, lon = loc.get("loc", "47.6,-122.3").split(",")
+
+ url = (
+ f"https://api.open-meteo.com/v1/forecast?"
+ f"latitude={lat}&longitude={lon}¤t_weather=true"
+ )
+ req2 = urllib.request.Request(url, headers={"User-Agent": "hookwise-tui"})
+ with urllib.request.urlopen(req2, timeout=3) as resp:
+ weather = json.loads(resp.read())
+
+ cw = weather.get("current_weather", {})
+ code = cw.get("weathercode", 0)
+ temp_c = cw.get("temperature", 10)
+ wind_speed = cw.get("windspeed", 5)
+
+ return WeatherInfo(
+ city=city,
+ condition=WEATHER_CODE_MAP.get(code, "cloudy"),
+ code=code,
+ temp_c=temp_c,
+ temp_f=round(temp_c * 9 / 5 + 32),
+ wind_speed=wind_speed,
+ )
+ except Exception:
+ return WeatherInfo(
+ city="Seattle",
+ condition="rain",
+ code=61,
+ temp_c=9,
+ temp_f=48,
+ wind_speed=12,
+ )
+
+
+def get_weather() -> WeatherInfo:
+ """Get weather info, preferring cache then falling back to live fetch.
+
+ Priority:
+ 1. Read from hookwise cache (written by weather feed producer)
+ 2. Fetch live from Open-Meteo (no API key needed)
+ """
+ cached = read_weather_from_cache()
+ if cached is not None:
+ return cached
+ return fetch_weather_live()
diff --git a/tui/tests/test_weather.py b/tui/tests/test_weather.py
new file mode 100644
index 0000000..970cc56
--- /dev/null
+++ b/tui/tests/test_weather.py
@@ -0,0 +1,487 @@
+"""Tests for weather background widget and weather data bridge."""
+
+import json
+
+import pytest
+
+from hookwise_tui.widgets.weather_data import (
+ WEATHER_CODE_MAP,
+ VALID_CONDITIONS,
+ WeatherInfo,
+ read_weather_from_cache,
+ get_weather,
+)
+from hookwise_tui.widgets.weather_background import (
+ WeatherBackground,
+ _Particle,
+ _Splash,
+ _Cloud,
+)
+
+
+# --- WeatherInfo dataclass ---
+
+
+class TestWeatherInfo:
+ def test_display_condition(self):
+ info = WeatherInfo(
+ city="Seattle", condition="rain", code=61,
+ temp_c=9, temp_f=48, wind_speed=12,
+ )
+ assert info.display_condition == "Rain"
+
+ def test_display_condition_clear(self):
+ info = WeatherInfo(
+ city="LA", condition="clear", code=0,
+ temp_c=25, temp_f=77, wind_speed=5,
+ )
+ assert info.display_condition == "Clear"
+
+ def test_display_condition_heavy_rain(self):
+ info = WeatherInfo(
+ city="Portland", condition="heavy_rain", code=65,
+ temp_c=7, temp_f=45, wind_speed=20,
+ )
+ assert info.display_condition == "Heavy Rain"
+
+ def test_condition_emoji(self):
+ info = WeatherInfo(
+ city="Seattle", condition="rain", code=61,
+ temp_c=9, temp_f=48, wind_speed=12,
+ )
+ assert info.condition_emoji != ""
+
+ def test_condition_emoji_snow(self):
+ info = WeatherInfo(
+ city="Denver", condition="snow", code=71,
+ temp_c=-2, temp_f=28, wind_speed=10,
+ )
+ assert info.condition_emoji != ""
+
+
+# --- Weather code mapping ---
+
+
+class TestWeatherCodeMap:
+ def test_clear_codes(self):
+ assert WEATHER_CODE_MAP[0] == "clear"
+ assert WEATHER_CODE_MAP[1] == "clear"
+
+ def test_rain_codes(self):
+ assert WEATHER_CODE_MAP[61] == "rain"
+ assert WEATHER_CODE_MAP[63] == "rain"
+ assert WEATHER_CODE_MAP[65] == "heavy_rain"
+
+ def test_snow_codes(self):
+ assert WEATHER_CODE_MAP[71] == "snow"
+ assert WEATHER_CODE_MAP[75] == "heavy_snow"
+
+ def test_thunderstorm_codes(self):
+ assert WEATHER_CODE_MAP[95] == "thunderstorm"
+ assert WEATHER_CODE_MAP[99] == "thunderstorm"
+
+ def test_drizzle_codes(self):
+ assert WEATHER_CODE_MAP[51] == "drizzle"
+ assert WEATHER_CODE_MAP[53] == "drizzle"
+
+ def test_fog_codes(self):
+ assert WEATHER_CODE_MAP[45] == "fog"
+ assert WEATHER_CODE_MAP[48] == "fog"
+
+ def test_all_conditions_are_valid(self):
+ for code, condition in WEATHER_CODE_MAP.items():
+ assert condition in VALID_CONDITIONS, (
+ f"Code {code} maps to '{condition}' which is not in VALID_CONDITIONS"
+ )
+
+
+# --- read_weather_from_cache ---
+
+
+class TestReadWeatherFromCache:
+ def test_reads_weather_from_cache(self):
+ cache = {
+ "weather": {
+ "city": "Seattle",
+ "condition": "rain",
+ "code": 61,
+ "temp_c": 9,
+ "temp_f": 48,
+ "wind_speed": 12,
+ }
+ }
+ result = read_weather_from_cache(cache)
+ assert result is not None
+ assert result.city == "Seattle"
+ assert result.condition == "rain"
+ assert result.code == 61
+ assert result.temp_c == 9.0
+ assert result.temp_f == 48.0
+
+ def test_returns_none_when_no_weather_key(self):
+ cache = {"pulse": {"updated_at": "2026-01-01T00:00:00Z"}}
+ result = read_weather_from_cache(cache)
+ assert result is None
+
+ def test_returns_none_when_weather_not_dict(self):
+ cache = {"weather": "rain"}
+ result = read_weather_from_cache(cache)
+ assert result is None
+
+ def test_returns_none_when_empty_cache(self):
+ result = read_weather_from_cache({})
+ assert result is None
+
+ def test_returns_none_when_empty_weather_entry(self):
+ cache = {"weather": {}}
+ result = read_weather_from_cache(cache)
+ assert result is None
+
+ def test_normalizes_invalid_condition(self):
+ cache = {
+ "weather": {
+ "city": "Narnia",
+ "condition": "magic_rain",
+ "code": 61,
+ "temp_c": 15,
+ "temp_f": 59,
+ "wind_speed": 5,
+ }
+ }
+ result = read_weather_from_cache(cache)
+ assert result is not None
+ assert result.condition == "rain" # Falls back to code mapping
+
+ def test_reads_producer_camelcase_schema(self):
+ """Test reading weather data from the TS producer's camelCase schema."""
+ cache = {
+ "weather": {
+ "temperature": 72,
+ "weatherCode": 0,
+ "windSpeed": 5,
+ "description": "Clear",
+ "emoji": "☀️",
+ "temperatureUnit": "fahrenheit",
+ }
+ }
+ result = read_weather_from_cache(cache)
+ assert result is not None
+ assert result.condition == "clear"
+ assert result.code == 0
+ assert result.temp_f == 72.0
+ assert result.temp_c == 22 # (72-32)*5/9 rounded
+ assert result.wind_speed == 5.0
+ assert result.city == "Local"
+
+ def test_reads_producer_celsius_schema(self):
+ """Test reading celsius weather data from the TS producer."""
+ cache = {
+ "weather": {
+ "temperature": 20,
+ "weatherCode": 61,
+ "windSpeed": 15,
+ "description": "Rain",
+ "emoji": "🌧️",
+ "temperatureUnit": "celsius",
+ }
+ }
+ result = read_weather_from_cache(cache)
+ assert result is not None
+ assert result.condition == "rain"
+ assert result.temp_c == 20.0
+ assert result.temp_f == 68 # 20*9/5+32 rounded
+ assert result.wind_speed == 15.0
+
+ def test_handles_missing_fields_gracefully(self):
+ cache = {
+ "weather": {
+ "city": "Seattle",
+ "condition": "cloudy",
+ }
+ }
+ result = read_weather_from_cache(cache)
+ assert result is not None
+ assert result.city == "Seattle"
+ assert result.condition == "cloudy"
+ assert result.code == 0
+ assert result.temp_c == 0.0
+ assert result.wind_speed == 0.0
+
+
+# --- Particle types ---
+
+
+class TestParticleTypes:
+ def test_particle_creation(self):
+ p = _Particle(x=10.0, y=5.0, char="|", speed=1.0, drift=0.1, opacity=2, layer=1)
+ assert p.x == 10.0
+ assert p.y == 5.0
+ assert p.char == "|"
+ assert p.speed == 1.0
+ assert p.drift == 0.1
+ assert p.opacity == 2
+ assert p.layer == 1
+
+ def test_particle_defaults(self):
+ p = _Particle(x=0, y=0, char=".", speed=0.5)
+ assert p.drift == 0.0
+ assert p.opacity == 2
+ assert p.layer == 0
+
+ def test_splash_creation(self):
+ s = _Splash(x=10.0, y=20.0)
+ assert s.x == 10.0
+ assert s.y == 20.0
+ assert s.life == 0
+ assert 3 <= s.max_life <= 6
+
+ def test_cloud_creation(self):
+ c = _Cloud(x=5.0, y=1.0, speed=0.05, shape_idx=0)
+ assert c.x == 5.0
+ assert c.y == 1.0
+ assert c.speed == 0.05
+ assert len(c.shape) > 0
+ assert c.width > 0
+
+ def test_cloud_shape_wrapping(self):
+ # Shape index wraps around
+ c1 = _Cloud(x=0, y=0, speed=0.05, shape_idx=0)
+ c2 = _Cloud(x=0, y=0, speed=0.05, shape_idx=len(_Cloud.SHAPES))
+ assert c1.shape == c2.shape
+
+
+# --- WeatherBackground widget ---
+
+
+class TestWeatherBackground:
+ def test_widget_renders_without_crash(self):
+ """Widget renders with default rain weather."""
+ from rich.text import Text as RichText
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "rain"
+ bg._spawn_all()
+ result = bg.render()
+ assert isinstance(result, RichText)
+ assert len(str(result)) > 0
+
+ def test_widget_renders_all_weather_types(self):
+ """Every weather condition renders without error."""
+ from rich.text import Text as RichText
+ for condition in VALID_CONDITIONS:
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = condition
+ bg._spawn_all()
+ bg._tick()
+ result = bg.render()
+ assert isinstance(result, RichText), f"Failed for condition: {condition}"
+
+ def test_spawn_particles_rain(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "rain"
+ bg._spawn_particles()
+ assert len(bg.particles) > 0
+ # Rain should have multi-layer particles
+ layers = {p.layer for p in bg.particles}
+ assert len(layers) > 1
+
+ def test_spawn_particles_snow(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "snow"
+ bg._spawn_particles()
+ assert len(bg.particles) > 0
+
+ def test_spawn_particles_clear(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "clear"
+ bg._spawn_particles()
+ # Clear has twinkling stars and dust motes
+ assert len(bg.particles) > 0
+
+ def test_spawn_particles_fog(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "fog"
+ bg._spawn_particles()
+ assert len(bg.particles) > 0
+
+ def test_spawn_particles_thunderstorm(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "thunderstorm"
+ bg._spawn_particles()
+ # Thunderstorm should be dense
+ assert len(bg.particles) > 80
+
+ def test_spawn_particles_heavy_rain(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "heavy_rain"
+ bg._spawn_particles()
+ assert len(bg.particles) > 80
+
+ def test_spawn_clouds(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "rain"
+ bg._spawn_clouds()
+ assert len(bg.clouds) >= 2
+
+ def test_spawn_clouds_clear(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "clear"
+ bg._spawn_clouds()
+ assert len(bg.clouds) == 1
+
+ def test_spawn_clouds_fog(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "fog"
+ bg._spawn_clouds()
+ assert len(bg.clouds) == 6
+
+ def test_weather_change_respawns(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "rain"
+ bg._spawn_all()
+
+ bg.weather = "snow"
+ bg.watch_weather("snow")
+
+ # Different weather = different particle count
+ # (they might coincidentally be the same due to randomness,
+ # but the particles themselves should be different types)
+ assert bg._wind == 0.0 # Wind resets on weather change
+
+ def test_tick_updates_frame(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "rain"
+ bg._spawn_all()
+ initial_frame = bg.frame
+ bg._tick()
+ assert bg.frame == initial_frame + 1
+
+ def test_render_produces_richtext(self):
+ from rich.text import Text as RichText
+ bg = WeatherBackground()
+ bg._width = 40
+ bg._height = 10
+ bg.weather = "rain"
+ bg._spawn_all()
+ result = bg.render()
+ assert isinstance(result, RichText)
+
+ def test_render_empty_dimensions(self):
+ from rich.text import Text as RichText
+ bg = WeatherBackground()
+ bg._width = 0
+ bg._height = 0
+ result = bg.render()
+ assert isinstance(result, RichText)
+ assert str(result) == ""
+
+ def test_lightning_only_in_thunderstorm(self):
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "rain"
+ bg._spawn_all()
+ # Run many ticks — rain should never trigger lightning
+ for _ in range(300):
+ bg._tick()
+ assert bg._lightning_flash == 0 # Rain doesn't have lightning
+
+ def test_splash_lifecycle(self):
+ # Test that splashes are created with life=0 and age each tick
+ splash = _Splash(40.0, 23.0)
+ assert splash.life == 0
+ assert 3 <= splash.max_life <= 6
+
+ # Test that splashes expire: create one with a short max_life
+ bg = WeatherBackground()
+ bg._width = 80
+ bg._height = 24
+ bg.weather = "clear" # Clear weather = no rain = no new splashes
+ bg._spawn_all()
+
+ short_splash = _Splash(40.0, 23.0)
+ short_splash.max_life = 3
+ bg.splashes = [short_splash]
+
+ # After enough ticks, the manually added splash should expire
+ for _ in range(10):
+ bg._tick()
+ # The short splash (max_life=3) should have expired in 3 ticks
+ assert short_splash not in bg.splashes
+
+
+# --- Integration: App with weather background ---
+
+
+class TestAppWeatherIntegration:
+ async def test_app_launches_with_weather_bg(self):
+ """App starts with weather background widget."""
+ from hookwise_tui.app import HookwiseTUI
+ app = HookwiseTUI()
+ async with app.run_test() as _pilot:
+ assert app.title == "Hookwise"
+ bg = app.query_one("#weather-bg", WeatherBackground)
+ assert bg is not None
+
+ async def test_weather_cycle_key(self):
+ """'w' key cycles through weather conditions."""
+ from hookwise_tui.app import HookwiseTUI
+ app = HookwiseTUI()
+ async with app.run_test() as pilot:
+ bg = app.query_one("#weather-bg", WeatherBackground)
+ initial_weather = bg.weather
+ await pilot.press("w")
+ # Weather should have changed
+ new_weather = bg.weather
+ # The cycle advances, so they should differ
+ # (unless the initial happens to be the last in cycle
+ # and wraps to the same — unlikely but possible)
+ assert isinstance(new_weather, str)
+ assert new_weather in VALID_CONDITIONS or new_weather == "sun"
+ assert new_weather != initial_weather
+
+ async def test_tab_switching_still_works(self):
+ """Tab switching works with weather background present."""
+ from hookwise_tui.app import HookwiseTUI
+ app = HookwiseTUI()
+ async with app.run_test() as pilot:
+ tabs = app.query_one("TabbedContent")
+ assert tabs.active == "dashboard"
+ await pilot.press("2")
+ assert tabs.active == "guards"
+ await pilot.press("8")
+ assert tabs.active == "status"
+
+ async def test_content_layer_exists(self):
+ """Content layer container is present above the weather background."""
+ from hookwise_tui.app import HookwiseTUI
+ app = HookwiseTUI()
+ async with app.run_test() as _pilot:
+ from textual.containers import Container
+ content = app.query_one("#content-layer", Container)
+ assert content is not None