Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
51 changes: 49 additions & 2 deletions internal/analytics/dolt.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,13 @@ func (d *DB) initSchema(ctx context.Context) error {
}

// Migration: add ttl_seconds to notifications (v2).
// Ignore error — column may already exist.
_, _ = d.db.ExecContext(ctx, "ALTER TABLE notifications ADD COLUMN ttl_seconds INT DEFAULT 86400")
// Only ignore "duplicate column" errors (column already exists).
if _, err := d.db.ExecContext(ctx, "ALTER TABLE notifications ADD COLUMN ttl_seconds INT DEFAULT 86400"); err != nil {
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
return fmt.Errorf("alter notifications add ttl_seconds: %w", err)
}
}

// Commit any schema changes so the working tree starts clean.
// On subsequent opens this will be a no-op (nothing to commit).
Expand All @@ -323,6 +328,48 @@ func (d *DB) initSchema(ctx context.Context) error {
return nil
}

// ---------------------------------------------------------------------------
// Guard Block Summaries (Task 11.1 — SQL extraction)
// ---------------------------------------------------------------------------

// GuardBlockSummary summarizes block events for a single tool pattern.
type GuardBlockSummary struct {
ToolName string
BlockCount int
}

// GuardBlockSummaries queries for tools that appear frequently in PreToolUse
// events on the given day, returning those with 5 or more occurrences.
//
// The today parameter should be a date prefix like "2006-01-02".
func (d *DB) GuardBlockSummaries(ctx context.Context, today string) ([]GuardBlockSummary, error) {
rows, err := d.db.QueryContext(ctx,
`SELECT tool_name, COUNT(*) AS cnt
FROM events
WHERE event_type = 'PreToolUse'
AND timestamp LIKE ?
AND tool_name != ''
GROUP BY tool_name
HAVING cnt >= 5
ORDER BY cnt DESC`,
today+"%",
Comment on lines +341 to +355

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Keep the guard-trigger threshold in one place.

GuardBlockSummaries now bakes in HAVING cnt >= 5, but internal/notifications/producers.go, Lines 83-86, still applies the same cutoff again. That split rule will drift the next time the threshold changes. Either return raw per-tool counts here or accept minCount as an argument and let the caller own the rule.

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

In `@internal/analytics/dolt.go` around lines 341 - 355, GuardBlockSummaries
currently hardcodes HAVING cnt >= 5; change its signature to accept a minCount
int (func (d *DB) GuardBlockSummaries(ctx context.Context, today string,
minCount int) ([]GuardBlockSummary, error)), update the SQL to use HAVING cnt >=
? and pass minCount as the parameter (instead of hardcoded 5), and then remove
the duplicate cutoff logic from the caller in the notifications producer so the
caller supplies the threshold rather than re-filtering results.

)
if err != nil {
return nil, fmt.Errorf("analytics: query guard blocks: %w", err)
}
defer rows.Close()

var summaries []GuardBlockSummary
for rows.Next() {
var s GuardBlockSummary
if err := rows.Scan(&s.ToolName, &s.BlockCount); err != nil {
return nil, fmt.Errorf("analytics: scan guard blocks: %w", err)
}
summaries = append(summaries, s)
}
return summaries, rows.Err()
}

// schemaDDL contains the ten table definitions from the design doc. Each
// statement is idempotent (CREATE TABLE IF NOT EXISTS).
var schemaDDL = []string{
Expand Down
18 changes: 0 additions & 18 deletions internal/analytics/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"time"

"github.com/vishnujayvel/hookwise/internal/core"
Expand Down Expand Up @@ -371,23 +370,6 @@ func (d *DB) ReadAllFeedCache(ctx context.Context) (map[string]interface{}, erro
return result, nil
}

// WriteFeedCacheJSON writes the feed cache data to the JSON bridge file
// at ~/.hookwise/state/status-line-cache.json for the Python TUI (R9.1).
//
// The output format is: {"key1": <data>, "key2": <data>, ...}
// This function respects ARCH-3: it is called from the dispatch process,
// not the daemon.
func WriteFeedCacheJSON(cacheData map[string]interface{}) error {
path := filepath.Join(core.GetStateDir(), "state", "status-line-cache.json")
return core.AtomicWriteJSON(path, cacheData)
}

// WriteFeedCacheJSONTo writes the feed cache data to a specified path.
// This variant is used in tests to avoid writing to the real state directory.
func WriteFeedCacheJSONTo(path string, cacheData map[string]interface{}) error {
return core.AtomicWriteJSON(path, cacheData)
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
Expand Down
88 changes: 1 addition & 87 deletions internal/analytics/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ package analytics

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -439,90 +436,7 @@ func TestReadAllFeedCache_MultipleEntries(t *testing.T) {
assert.Equal(t, true, sessionData["active"])
}

// Test 15: WriteFeedCacheJSON creates JSON file at correct path
func TestWriteFeedCacheJSON_CreatesFile(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hookwise-feed-json-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

outPath := filepath.Join(tmpDir, "status-line-cache.json")

cacheData := map[string]interface{}{
"weather": map[string]interface{}{"temp": float64(72)},
"costs": map[string]interface{}{"total": float64(5.5)},
}

require.NoError(t, WriteFeedCacheJSONTo(outPath, cacheData))

// Verify file exists.
info, err := os.Stat(outPath)
require.NoError(t, err)
assert.True(t, info.Size() > 0)
}

// Test 16: WriteFeedCacheJSON file content matches expected format
func TestWriteFeedCacheJSON_ContentFormat(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hookwise-feed-json-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

outPath := filepath.Join(tmpDir, "status-line-cache.json")

cacheData := map[string]interface{}{
"weather": map[string]interface{}{
"temperature": float64(72),
"unit": "F",
},
"session_info": map[string]interface{}{
"active": true,
"duration": float64(300),
},
}

require.NoError(t, WriteFeedCacheJSONTo(outPath, cacheData))

// Read the file back and verify structure.
raw, err := os.ReadFile(outPath)
require.NoError(t, err)

var parsed map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &parsed))

// Top-level keys should be the cache keys.
assert.Contains(t, parsed, "weather")
assert.Contains(t, parsed, "session_info")

// Verify nested structure.
weather, ok := parsed["weather"].(map[string]interface{})
require.True(t, ok, "weather should be a map")
assert.Equal(t, float64(72), weather["temperature"])
assert.Equal(t, "F", weather["unit"])

session, ok := parsed["session_info"].(map[string]interface{})
require.True(t, ok, "session_info should be a map")
assert.Equal(t, true, session["active"])
assert.Equal(t, float64(300), session["duration"])
}

// Test 17: WriteFeedCacheJSON with empty map creates valid JSON
func TestWriteFeedCacheJSON_EmptyMap(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "hookwise-feed-json-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

outPath := filepath.Join(tmpDir, "status-line-cache.json")

require.NoError(t, WriteFeedCacheJSONTo(outPath, map[string]interface{}{}))

raw, err := os.ReadFile(outPath)
require.NoError(t, err)

var parsed map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &parsed))
assert.Empty(t, parsed)
}

// Test 18: Feed cache overwrite replaces existing entry
// Test 15: Feed cache overwrite replaces existing entry
func TestFeedCache_OverwriteReplaces(t *testing.T) {
db, cleanup := testOpen(t)
defer cleanup()
Expand Down
51 changes: 51 additions & 0 deletions internal/core/analytics_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package core

// --- Analytics Types ---

type AIClassification string

const (
AIClassHighProbability AIClassification = "high_probability_ai"
AIClassLikelyAI AIClassification = "likely_ai"
AIClassMixedVerified AIClassification = "mixed_verified"
AIClassHumanAuthored AIClassification = "human_authored"
)

type AIConfidenceScore struct {
Score float64 `json:"score"`
Classification AIClassification `json:"classification"`
}

type AnalyticsEvent struct {
SessionID string `json:"sessionId"`
EventType string `json:"eventType"`
ToolName string `json:"toolName,omitempty"`
Timestamp string `json:"timestamp"`
FilePath string `json:"filePath,omitempty"`
LinesAdded int `json:"linesAdded,omitempty"`
LinesRemoved int `json:"linesRemoved,omitempty"`
AIConfidenceScore *float64 `json:"aiConfidenceScore,omitempty"`
}

type SessionSummary struct {
TotalToolCalls int `json:"totalToolCalls"`
FileEditsCount int `json:"fileEditsCount"`
AIAuthoredLines int `json:"aiAuthoredLines"`
HumanVerifiedLines int `json:"humanVerifiedLines"`
Classification string `json:"classification,omitempty"`
EstimatedCostUSD float64 `json:"estimatedCostUsd,omitempty"`
}

type AuthorshipSummary struct {
TotalEntries int `json:"totalEntries"`
TotalLinesChanged int `json:"totalLinesChanged"`
WeightedAIScore float64 `json:"weightedAIScore"`
ClassificationBreakdown map[AIClassification]int `json:"classificationBreakdown"`
}

type StatsOptions struct {
SessionID string `json:"sessionId,omitempty"`
Days int `json:"days,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
}
136 changes: 136 additions & 0 deletions internal/core/config_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package core

// --- Config Types ---

// DispatchConfig holds settings for the dispatch pipeline.
type DispatchConfig struct {
TimeoutMs int `yaml:"timeout_ms" json:"timeoutMs"`
}

// HooksConfig is the top-level configuration structure.
type HooksConfig struct {
Version int `yaml:"version" json:"version"`
Guards []GuardRuleConfig `yaml:"guards" json:"guards"`
Coaching CoachingConfig `yaml:"coaching" json:"coaching"`
Analytics AnalyticsConfig `yaml:"analytics" json:"analytics"`
Greeting GreetingConfig `yaml:"greeting" json:"greeting"`
Sounds SoundsConfig `yaml:"sounds" json:"sounds"`
StatusLine StatusLineConfig `yaml:"status_line" json:"statusLine"`
CostTracking CostTrackingConfig `yaml:"cost_tracking" json:"costTracking"`
TranscriptBackup TranscriptConfig `yaml:"transcript_backup" json:"transcriptBackup"`
Handlers []CustomHandlerConfig `yaml:"handlers" json:"handlers"`
Settings SettingsConfig `yaml:"settings" json:"settings"`
Includes []string `yaml:"includes" json:"includes"`
Feeds FeedsConfig `yaml:"feeds" json:"feeds"`
Daemon DaemonConfig `yaml:"daemon" json:"daemon"`
TUI TUIConfig `yaml:"tui" json:"tui"`
Dispatch DispatchConfig `yaml:"dispatch" json:"dispatch"`
}

type CoachingConfig struct {
Metacognition MetacognitionConfig `yaml:"metacognition" json:"metacognition"`
BuilderTrap BuilderTrapConfig `yaml:"builder_trap" json:"builderTrap"`
Communication CommunicationConfig `yaml:"communication" json:"communication"`
}

type MetacognitionConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"`
PromptsFile string `yaml:"prompts_file,omitempty" json:"promptsFile,omitempty"`
}

type BuilderTrapConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Thresholds BuilderTrapThresholds `yaml:"thresholds" json:"thresholds"`
ToolingPatterns []string `yaml:"tooling_patterns" json:"toolingPatterns"`
PracticeTools []string `yaml:"practice_tools" json:"practiceTools"`
}

type BuilderTrapThresholds struct {
Yellow int `yaml:"yellow" json:"yellow"`
Orange int `yaml:"orange" json:"orange"`
Red int `yaml:"red" json:"red"`
}

type CommunicationConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Frequency int `yaml:"frequency" json:"frequency"`
MinLength int `yaml:"min_length" json:"minLength"`
Rules []string `yaml:"rules" json:"rules"`
Tone string `yaml:"tone" json:"tone"` // "gentle", "direct", "silent"
}

type AnalyticsConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
DBPath string `yaml:"db_path,omitempty" json:"dbPath,omitempty"`
}

type GreetingConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
QuotesFile string `yaml:"quotes_file,omitempty" json:"quotesFile,omitempty"`
Categories map[string]QuoteCategoryConfig `yaml:"categories,omitempty" json:"categories,omitempty"`
}

type QuoteCategoryConfig struct {
Weight int `yaml:"weight" json:"weight"`
Quotes []QuoteEntry `yaml:"quotes" json:"quotes"`
}

type QuoteEntry struct {
Text string `yaml:"text" json:"text"`
Author string `yaml:"author,omitempty" json:"author,omitempty"`
}

type SoundsConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Notification string `yaml:"notification,omitempty" json:"notification,omitempty"`
Completion string `yaml:"completion,omitempty" json:"completion,omitempty"`
}

type StatusLineConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Segments []SegmentConfig `yaml:"segments" json:"segments"`
Delimiter string `yaml:"delimiter" json:"delimiter"`
CachePath string `yaml:"cache_path" json:"cachePath"`
}

type CostTrackingConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Rates map[string]float64 `yaml:"rates" json:"rates"`
DailyBudget float64 `yaml:"daily_budget" json:"dailyBudget"`
Enforcement string `yaml:"enforcement" json:"enforcement"` // "warn" or "enforce"
}

type TranscriptConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
BackupDir string `yaml:"backup_dir" json:"backupDir"`
MaxSizeMB int `yaml:"max_size_mb" json:"maxSizeMb"`
}
Comment on lines +104 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Inconsistent casing in JSON tag for MaxSizeMB.

The field name uses MB (megabytes abbreviation) but the JSON tag uses maxSizeMb. For consistency with the field name and standard abbreviation conventions, consider using maxSizeMB.

✏️ Suggested fix
 type TranscriptConfig struct {
 	Enabled   bool   `yaml:"enabled" json:"enabled"`
 	BackupDir string `yaml:"backup_dir" json:"backupDir"`
-	MaxSizeMB int    `yaml:"max_size_mb" json:"maxSizeMb"`
+	MaxSizeMB int    `yaml:"max_size_mb" json:"maxSizeMB"`
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type TranscriptConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
BackupDir string `yaml:"backup_dir" json:"backupDir"`
MaxSizeMB int `yaml:"max_size_mb" json:"maxSizeMb"`
}
type TranscriptConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
BackupDir string `yaml:"backup_dir" json:"backupDir"`
MaxSizeMB int `yaml:"max_size_mb" json:"maxSizeMB"`
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/core/config_types.go` around lines 104 - 108, The JSON tag for
TranscriptConfig.MaxSizeMB uses inconsistent casing ("maxSizeMb"); update the
struct tag for the MaxSizeMB field in type TranscriptConfig to "maxSizeMB" so
the JSON key matches the field's MB abbreviation and project conventions—i.e.,
change the `json:"maxSizeMb"` on MaxSizeMB to `json:"maxSizeMB"`.


type SettingsConfig struct {
LogLevel string `yaml:"log_level" json:"logLevel"` // "debug", "info", "warn", "error"
HandlerTimeoutSeconds int `yaml:"handler_timeout_seconds" json:"handlerTimeoutSeconds"`
StateDir string `yaml:"state_dir" json:"stateDir"`
}

type CustomHandlerConfig struct {
Name string `yaml:"name" json:"name"`
Type string `yaml:"type" json:"type"` // "builtin", "script", "inline"
Events []string `yaml:"events" json:"events"`
Phase string `yaml:"phase,omitempty" json:"phase,omitempty"`
Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"`
Module string `yaml:"module,omitempty" json:"module,omitempty"`
Command string `yaml:"command,omitempty" json:"command,omitempty"`
Action map[string]interface{} `yaml:"action,omitempty" json:"action,omitempty"`
}

type DaemonConfig struct {
AutoStart bool `yaml:"auto_start" json:"autoStart"`
InactivityTimeoutMinutes int `yaml:"inactivity_timeout_minutes" json:"inactivityTimeoutMinutes"`
LogFile string `yaml:"log_file" json:"logFile"`
}

type TUIConfig struct {
AutoLaunch bool `yaml:"auto_launch" json:"autoLaunch"`
LaunchMethod string `yaml:"launch_method" json:"launchMethod"` // "newWindow" or "background"
}
Loading
Loading