Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default defineConfig({
text: 'About',
items: [
{ text: 'Architecture', link: '/architecture' },
{ text: 'Tech Stack', link: '/tech-stack' },
{ text: 'Philosophy', link: '/philosophy' }
]
}
Expand Down
58 changes: 47 additions & 11 deletions docs/philosophy.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,63 @@
# Philosophy

hookwise was born from watching Claude Code do amazing things -- and occasionally terrifying things.
hookwise is a platform for guarding, coaching, and observing Claude Code sessions.

## Why hookwise Exists

I built hookwise because I kept waking up to 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, observability, and coaching.

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, no analytics, and no way to know if your guards actually work until something slips through.

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 replaces all of that with a platform: declarative guards, analytics that tell you where your time actually goes, coaching that nudges you when you have been in tooling mode for 90 minutes, feeds that surface context in your status line, recipes that let you share configurations, and a TUI that ties it all together.

**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.
## Core Principles

## Design Principle
### 1. Platform, Not Just Config

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.
hookwise started as a YAML config layer for guard rails. It is now a platform with a SQLite analytics engine, a cache bus, coaching state, a feed daemon, a TUI, and a recipe system. YAML remains the right format for declaring guards, coaching rules, and feed settings -- the things that should be human-readable, git-tracked, and declarative. But YAML is not the whole platform.

> *"Guard rails should be boring. The exciting part is what you build when you are not worried about what your AI is doing."*
The platform needs a data layer, not just a config layer. Recipes need a standard way to persist structured data. Analytics needs a durable store. Coaching needs memory across sessions. Without a standard data layer, every recipe author invents their own storage, every new feature adds another ad hoc file, and the platform fragments.

## Fail-Open
hookwise should provide a shared data layer that any subsystem or recipe can use. The right tool for that layer -- SQLite, a managed database, purpose-built files -- is an engineering question, not a philosophical one.

If any part of hookwise itself errors, it fails open. hookwise must never accidentally block a tool call due to internal errors. Any unhandled exception anywhere in the dispatch pipeline results in `exit 0`.
### 2. Degrade Gracefully, Always Warn

If any part of hookwise errors, the session continues. hookwise must never accidentally block a tool call due to internal errors. Any unhandled exception in the dispatch pipeline results in `exit 0`.

But graceful degradation must not mean silent degradation. The v1.3 retro showed that "fail-open" was interpreted as "fail-silent" -- 5 of 7 launch bugs shipped unnoticed because errors were swallowed without any signal to the user. Every degradation must produce a visible diagnostic: a status line warning, a cache bus key, a log entry. The user should always know when something is running in a reduced state.

**Degrade gracefully AND always warn the user.**

Graceful degradation is a design practice, not a philosophical argument against infrastructure. A database server that crashes should not take down your Claude Code session -- but the possibility of a crash is not a reason to avoid running a database server. The question is whether the infrastructure solves a real problem and whether hookwise handles its failure modes correctly.

### 3. Infrastructure Is Normal

hookwise is a developer tool platform. Developer tool platforms have databases, background daemons, and managed processes. This is normal and expected.

hookwise already runs a background daemon for feeds, manages PID files, bundles better-sqlite3 as a native addon, and ships a Python TUI package. The bar for adding infrastructure should be: "Does this solve a real problem for users?" Not: "Is this zero-config?" and not: "Could this possibly go down?"

What matters is that infrastructure is invisible to the user when it works, and diagnostic when it does not.

### 4. Guards Should Be Boring; the Platform Should Not

Guard rails should be declarative, predictable, and unexciting. You write YAML rules that read like firewall policies. First block wins. No magic. The exciting part is what you build when you are not worried about what your AI is doing.

This principle applies specifically to the guard system. Other parts of hookwise -- analytics dashboards, coaching nudges, feed-backed status lines, the TUI, recipe ecosystems -- can and should be rich, interactive, and engaging. The platform grows; guards stay boring.

### 5. Recipes Need a Data Contract

A recipe is a shareable hookwise configuration: guards, coaching rules, feed producers, status line segments. Today, a recipe is a directory with `hooks.yaml`. That is enough for declarative configuration but not enough for recipes that need to persist data.

A recipe that tracks code review patterns needs a table. A recipe that scores commit quality needs a rolling window of scores. A recipe that detects dependency drift needs a snapshot of the last lockfile. Without a standard data layer, each recipe author builds their own persistence -- different formats, different locations, different cleanup strategies.

hookwise should define a data contract for recipes: a standard way to create tables, read/write rows, and query data. The underlying engine is an implementation detail. The contract is what makes recipes portable and composable.

### 6. Composition Over Extension

New capabilities should be composable from existing subsystems. A new feed producer is a function. A new guard is a YAML rule. A new recipe is a directory with `hooks.yaml` and optionally a data schema. A new status line segment is a pure function of cache and config.

When composition is not enough and a new architectural layer is needed, that is a signal to design it carefully -- not a signal to avoid it.

---

[Back to Home](/)
<- [Back to Home](/)
96 changes: 96 additions & 0 deletions hooks/agent-tracker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# hooks/agent-tracker.sh
#
# Claude Code hook for SubagentStart / SubagentStop events.
# Reads JSON from stdin, maintains state at ~/.hookwise/cache/active-agents.json.
# Always exits 0 (fail-open).
set -uo pipefail

STATE_DIR="${HOOKWISE_STATE_DIR:-$HOME/.hookwise}"
CACHE_DIR="$STATE_DIR/cache"
STATE_FILE="$CACHE_DIR/active-agents.json"
STALE_SECONDS=600 # 10 minutes

mkdir -p "$CACHE_DIR"

# Read stdin JSON
INPUT=$(cat)

# Extract event type from CLAUDE_CODE_HOOK_EVENT_NAME env var
EVENT="${CLAUDE_CODE_HOOK_EVENT_NAME:-}"

# Parse fields from stdin JSON
agent_id=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('agent_id',''))" 2>/dev/null || echo "")
session_id=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('session_id',''))" 2>/dev/null || echo "")
worktree=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")
team_name=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('team_name',''))" 2>/dev/null || echo "")
strategy=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('strategy',''))" 2>/dev/null || echo "")

if [ -z "$agent_id" ]; then
exit 0
fi

# Derive a short name from worktree basename or truncated agent_id
if [ -n "$worktree" ]; then
name=$(basename "$worktree")
else
name="${agent_id:0:12}"
fi

NOW=$(date +%s)

# Read existing state (or start fresh)
if [ -f "$STATE_FILE" ]; then
CURRENT=$(cat "$STATE_FILE" 2>/dev/null || echo '{"agents":[],"team_name":"","strategy":""}')
else
CURRENT='{"agents":[],"team_name":"","strategy":""}'
fi
Comment on lines +42 to +47

@coderabbitai coderabbitai Bot Mar 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a lock around read-modify-write to prevent lost updates.

Concurrent SubagentStart/SubagentStop hooks can race: both read old state, then last writer wins and drops the other update.

Also applies to: 91-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/agent-tracker.sh` around lines 42 - 47, The current read-modify-write
around STATE_FILE using CURRENT is racy—wrap both the read and write when
handling SubagentStart/SubagentStop so updates cannot interleave; acquire an
exclusive lock (e.g., using flock on a dedicated lockfile or mkdir-based lock)
before reading CURRENT, perform the modify, then atomically write the updated
STATE_FILE and release the lock; ensure the same locking is applied to the other
block that handles state (the similar logic referenced around the later
SubagentStart/SubagentStop handling) so all accesses to STATE_FILE use the same
lock identifier.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring. The values interpolated into the Python block come from Claude Code's hook event JSON payload — a trusted internal source, not user input. The agent_id, name, etc. are system-generated identifiers. Agreed that stdin-based passing would be more robust, but the injection risk here is theoretical given the trusted source.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vishnujayvel, just a quick clarification — my original concern was specifically about concurrent execution race conditions (two Claude Code hook processes running simultaneously, both reading the old STATE_FILE before either has written back, so one update silently overwrites the other), rather than injection risk from the payload values.

Your point about the trusted source is valid for the injection angle, but the race condition is independent of data trust — it's a filesystem concurrency issue. That said, since you're deferring, understood! If you open a follow-up issue later, the fix would be something like:

LOCK_FILE="$CACHE_DIR/.agent-tracker.lock"
(
  flock -x 200
  # ... read CURRENT, modify, write STATE_FILE atomically ...
) 200>"$LOCK_FILE"

No action needed from your side right now.


# Use python3 for JSON manipulation (available on macOS)
UPDATED=$(python3 -c "
import json, sys

current = json.loads('''$CURRENT''')
agents = current.get('agents', [])
event = '$EVENT'
agent_id = '$agent_id'
name = '$name'
now = $NOW
stale = $STALE_SECONDS
team_name = '$team_name' or current.get('team_name', '')
strategy = '$strategy' or current.get('strategy', '')

Comment on lines +50 to +62

@coderabbitai coderabbitai Bot Mar 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Inline Python interpolation is injection-prone and brittle.

Unescaped values ($CURRENT, $agent_id, $team_name, etc.) are embedded directly into Python code. A quote/newline in input can break parsing or execute unintended Python statements.

🔒 Safer pattern (pass data as argv/stdin, not source interpolation)
-UPDATED=$(python3 -c "
-import json, sys
-current = json.loads('''$CURRENT''')
-event = '$EVENT'
-agent_id = '$agent_id'
-name = '$name'
-now = $NOW
-stale = $STALE_SECONDS
-team_name = '$team_name' or current.get('team_name', '')
-strategy = '$strategy' or current.get('strategy', '')
-...
-print(json.dumps(result, indent=2))
-" 2>/dev/null) || exit 0
+UPDATED=$(
+python3 - "$CURRENT" "$EVENT" "$agent_id" "$name" "$NOW" "$STALE_SECONDS" "$team_name" "$strategy" <<'PY'
+import json, sys
+current_json, event, agent_id, name, now_s, stale_s, team_name, strategy = sys.argv[1:]
+current = json.loads(current_json) if current_json else {"agents": [], "team_name": "", "strategy": ""}
+now = int(now_s)
+stale = int(stale_s)
+agents = current.get("agents", [])
+team_name = team_name or current.get("team_name", "")
+strategy = strategy or current.get("strategy", "")
+# ...existing mutation logic...
+print(json.dumps({"agents": agents, "team_name": team_name, "strategy": strategy, "updated_at": now}, indent=2))
+PY
+) 2>/dev/null || exit 0

Also applies to: 66-89

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/agent-tracker.sh` around lines 50 - 62, The inline python block
assigning UPDATED is injection-prone because it interpolates shell variables
directly into the -c string; instead invoke python3 with no source interpolation
by passing the JSON payload via stdin (read with json.loads(sys.stdin.read()))
and pass scalars (EVENT, agent_id, name, NOW, STALE_SECONDS, team_name,
strategy) as safe argv values or environment variables (access via sys.argv or
os.environ) so you avoid embedding unescaped values; update the python3 -c call
that produces UPDATED to read CURRENT from stdin and read other inputs from
sys.argv (and apply the fallback logic for team_name/strategy using
current.get(...) inside the Python code) to eliminate quote/newline injection
risks.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6cf685d. Changed set -euo pipefail to set -uo pipefail — the script already has || exit 0 and 2>/dev/null guards on failure-prone operations, and ends with explicit exit 0. Removing -e ensures intermediate failures don't short-circuit the fail-open contract.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

Let me check the actual commit to verify what was changed.


🏁 Script executed:

#!/bin/bash
gh api repos/vishnujayvel/hookwise/commits/6cf685d --jq '.sha, .commit.message' 2>/dev/null || echo "not found"
gh api repos/vishnujayvel/hookwise/commits/6cf685d/files --jq '.[].patch' 2>/dev/null | head -100

Length of output: 431


🏁 Script executed:

#!/bin/bash
# Try to get the diff via compare API or PR commits
gh api repos/vishnujayvel/hookwise/pulls/16/commits --jq '.[].sha + " " + .[].commit.message' 2>/dev/null | tail -5

# Try to view current state of the file
gh api repos/vishnujayvel/hookwise/contents/hooks/agent-tracker.sh --jq '.content' 2>/dev/null | base64 -d | head -70

Length of output: 2594


@vishnujayvel, thanks for the set -e fix — that does correctly preserve the fail-open contract. However, the original finding I raised is still present in the current file.

The set -e removal was a separate concern. My comment was specifically about the UPDATED=$(python3 -c "...") block where shell variables are interpolated directly into Python source code:

# Still in the current file (lines ~50+):
UPDATED=$(python3 -c "
current = json.loads('''$CURRENT''')   # $CURRENT is a JSON blob — breakable with ''' in any value
event = '$EVENT'
agent_id = '$agent_id'                  # single-quote in agent_id breaks Python syntax
name = '$name'
team_name = '$team_name' or ...
strategy = '$strategy' or ...
")

Why this is still risky: $CURRENT is read from disk (trusted), but $agent_id, $name, $team_name, and $strategy are extracted from Claude Code hook input, which could contain quotes or newlines from an agent's working directory path (e.g. a cwd with a ' in it). A path like /Users/alice/it's-a-project would break the Python -c string.

The safer fix from my original suggestion — passing values via sys.argv or a heredoc instead of source interpolation — is still worth applying:

UPDATED=$(
python3 - "$CURRENT" "$EVENT" "$agent_id" "$name" "$NOW" "$STALE_SECONDS" "$team_name" "$strategy" <<'PY'
import json, sys
current_json, event, agent_id, name, now_s, stale_s, team_name, strategy = sys.argv[1:]
current = json.loads(current_json) if current_json else {"agents": [], "team_name": "", "strategy": ""}
now = int(now_s)
stale = int(stale_s)
agents = current.get("agents", [])
team_name = team_name or current.get("team_name", "")
strategy = strategy or current.get("strategy", "")
# ... rest of mutation logic unchanged ...
PY
) 2>/dev/null || true

This pattern is immune to quote injection because values never touch Python source — they're passed as process arguments and read via sys.argv.

# Clean stale entries (older than 10 minutes)
agents = [a for a in agents if (now - a.get('started_at', now)) < stale]

if event == 'SubagentStart':
# Remove existing entry with same agent_id (if re-started)
agents = [a for a in agents if a.get('agent_id') != agent_id]
agents.append({
'agent_id': agent_id,
'name': name,
'status': 'working',
'started_at': now
})
elif event == 'SubagentStop':
for a in agents:
if a.get('agent_id') == agent_id:
a['status'] = 'done'
a['stopped_at'] = now
break

result = {
'agents': agents,
'team_name': team_name,
'strategy': strategy,
'updated_at': now
}
print(json.dumps(result, indent=2))
" 2>/dev/null) || exit 0

# Atomic write via temp file + rename
TMPFILE="$CACHE_DIR/.active-agents.tmp.$$"
echo "$UPDATED" > "$TMPFILE"
mv "$TMPFILE" "$STATE_FILE"

exit 0
2 changes: 1 addition & 1 deletion src/cli/commands/stats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function loadStats(
agents: null,
costState: null,
streaks: null,
error: `No analytics database found. Run "hookwise init --preset analytics" to enable, then use Claude Code to generate data.`,
error: `No analytics database found. Analytics is enabled by default — use Claude Code to generate data. If disabled, run "hookwise init --preset analytics" to re-enable.`,
};
}

Expand Down
10 changes: 10 additions & 0 deletions src/cli/commands/status-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
*/

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { safeReadJSON, atomicWriteJSON } from "../../core/state.js";
import { DEFAULT_CACHE_PATH } from "../../core/constants.js";
import { renderTwoTier, DEFAULT_TWO_TIER_CONFIG } from "../../core/status-line/two-tier.js";

const ACTIVE_AGENTS_PATH = join(homedir(), ".hookwise", "cache", "active-agents.json");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Stdin data shape from Claude Code's status line protocol.
*/
Expand Down Expand Up @@ -74,6 +78,12 @@ export async function runStatusLineCommand(): Promise<void> {
};
}

// Load active agents data for the agents segment
const agentsData = safeReadJSON<Record<string, unknown>>(ACTIVE_AGENTS_PATH, {});
if (agentsData && Object.keys(agentsData).length > 0) {
cache.agents = agentsData;
}

// Advance rotation index for line 2 cycling
const prevIndex = (cache._rotation_index as number) ?? 0;
const nextIndex = prevIndex + 1;
Expand Down
3 changes: 3 additions & 0 deletions src/core/analytics/authorship.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
/**
* @experimental This module is not part of the public API and should not
* be used in production. It may change or be removed without notice.
*
* Authorship Ledger with AI Confidence Scoring for hookwise analytics.
*
* Implements heuristic-based AI confidence scoring that estimates
Expand Down
2 changes: 1 addition & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function getDefaultConfig(): HooksConfig {
tone: "gentle",
},
},
analytics: { enabled: false },
analytics: { enabled: true },
greeting: { enabled: false },
sounds: { enabled: false },
statusLine: {
Expand Down
2 changes: 1 addition & 1 deletion src/core/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export function dispatch(
// Feed platform: write heartbeat + CWD for daemon consumption (on every dispatch)
try {
const cachePath = config.statusLine.cachePath;
mergeKey(cachePath, "_heartbeat", { value: Date.now() }, 999999);
mergeKey(cachePath, "_dispatch_heartbeat", { value: Date.now() }, 999999);
mergeKey(cachePath, "_cwd", { value: process.cwd() }, 999999);
} catch {
// Fail-open: feed cache write errors must never affect dispatch result
Expand Down
5 changes: 4 additions & 1 deletion src/core/feeds/daemon-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,16 @@ function buildFeedHealth(
): FeedHealth[] {
const health: FeedHealth[] = [];

// Built-in feeds
// Built-in feeds (all 8)
const builtinFeeds = [
{ name: "pulse", enabled: feedsConfig.pulse.enabled, intervalSeconds: feedsConfig.pulse.intervalSeconds },
{ name: "project", enabled: feedsConfig.project.enabled, intervalSeconds: feedsConfig.project.intervalSeconds },
{ name: "calendar", enabled: feedsConfig.calendar.enabled, intervalSeconds: feedsConfig.calendar.intervalSeconds },
{ name: "news", enabled: feedsConfig.news.enabled, intervalSeconds: feedsConfig.news.intervalSeconds },
{ name: "insights", enabled: feedsConfig.insights.enabled, intervalSeconds: feedsConfig.insights.intervalSeconds },
{ name: "practice", enabled: feedsConfig.practice.enabled, intervalSeconds: feedsConfig.practice.intervalSeconds },
{ name: "weather", enabled: feedsConfig.weather.enabled, intervalSeconds: feedsConfig.weather.intervalSeconds },
{ name: "memories", enabled: feedsConfig.memories.enabled, intervalSeconds: feedsConfig.memories.intervalSeconds },
];

for (const feed of builtinFeeds) {
Expand Down
35 changes: 29 additions & 6 deletions src/core/feeds/daemon-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ const MAX_LOG_LINES = 1000;
/** Lines to keep after rotation. */
const KEEP_LOG_LINES = 500;

/** Heartbeat TTL: effectively infinite (used as a sentinel, not for freshness). */
const HEARTBEAT_TTL = 999999;
/** Daemon heartbeat TTL: 90 seconds (refreshed every 30s, stale = daemon crashed). */
const DAEMON_HEARTBEAT_TTL = 90;

/** Daemon heartbeat interval: 30 seconds. */
const DAEMON_HEARTBEAT_INTERVAL_MS = 30_000;

// --- Logging ---

Expand Down Expand Up @@ -253,8 +256,23 @@ export async function runDaemon(projectDir: string): Promise<void> {
writeFileSync(pidPath, String(process.pid), "utf-8");
daemonLog(`PID ${process.pid} written to ${pidPath}`, logFile);

// Step 5 (partial): Write initial heartbeat
mergeKey(cachePath, "_heartbeat", { value: Date.now() }, HEARTBEAT_TTL);
// Step 5 (partial): Write initial daemon heartbeat + start refresh interval
try {
mergeKey(cachePath, "_daemon_heartbeat", { value: Date.now() }, DAEMON_HEARTBEAT_TTL);
} catch (error) {
daemonLog(
`Initial daemon heartbeat write failed: ${error instanceof Error ? error.message : String(error)}`,
logFile,
);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const heartbeatTimer = setInterval(() => {
try {
mergeKey(cachePath, "_daemon_heartbeat", { value: Date.now() }, DAEMON_HEARTBEAT_TTL);
} catch {
// Fail-open: heartbeat write errors must never crash daemon
}
}, DAEMON_HEARTBEAT_INTERVAL_MS);

// Step 4: Start staggered intervals for enabled feeds
const enabledFeeds = registry.getEnabled();
Expand All @@ -281,14 +299,19 @@ export async function runDaemon(projectDir: string): Promise<void> {
intervalTimers.push(initialTimeout);
}

intervalTimers.push(heartbeatTimer);

daemonLog(`Started ${enabledFeeds.length} feed(s) with staggered intervals`, logFile);

// Step 5: Inactivity monitoring
const inactivityTimer = setInterval(() => {
try {
const parsed = readAll(cachePath);
const heartbeat = (parsed?._heartbeat as Record<string, unknown>)?.value as number | undefined;

const rawHeartbeat = (parsed?._dispatch_heartbeat as Record<string, unknown>)?.value;
const heartbeat =
typeof rawHeartbeat === "number" && Number.isFinite(rawHeartbeat)
? rawHeartbeat
: undefined;
const referenceTime = heartbeat ?? daemonStartTime;
const timeoutMs = config.daemon.inactivityTimeoutMinutes * 60 * 1000;

Expand Down
8 changes: 6 additions & 2 deletions src/core/feeds/producers/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,19 @@ export function aggregateInsights(
.slice(0, 5)
.map(([name, count]) => ({ name, count }));

let peakHour = 0;
let peakHourUtc = 0;
let maxHourCount = 0;
for (let h = 0; h < 24; h++) {
if (hourCounts[h] > maxHourCount) {
maxHourCount = hourCounts[h];
peakHour = h;
peakHourUtc = h;
}
}

const offsetMinutes = new Date(now).getTimezoneOffset();
const localPeakMinutes = (peakHourUtc * 60 - offsetMinutes + 24 * 60) % (24 * 60);
const peakHour = Math.floor(localPeakMinutes / 60);

const frictionTotal = Object.values(frictionCounts).reduce((sum, v) => sum + v, 0);

const result: InsightsData = {
Expand Down
3 changes: 1 addition & 2 deletions src/core/feeds/producers/practice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
* 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:
* and writes to the "practice" cache key consumed by the practice segment:
* - practice: displays "TARGET todayTotal today"
* - practice_breadcrumb: displays "Last practice: Xm ago"
*
* Returns null when:
* - The database file does not exist
Expand Down
Loading