A finding you have to remember to go and look at is a finding you do not see. This closes the loop: `fathom insights` reports findings for other programs, a Claude Code Stop hook runs it when a turn ends, and /insights shows the detail on request. The CLI reports as text, --json, or --jsonl, and --new returns only what has not been reported before. The server owns the reported-findings state, because two sessions can end at the same moment and a read-then-write would have both report the same thing. Two rules follow from running on every turn: - It must be fast. --hook asks a running server for an analysis already computed rather than doing one, and never falls back to analysing directly, which could take seconds. Measured at ~20ms whether or not a server is up. - It must never disrupt the session it observes. Every path exits 0 and prints at most one line. A Stop hook exiting 2 tells Claude Code to keep going rather than stop, so a crash here would hijack the session it was supposed to quietly watch. Surfacing is a higher bar than appearing in the tab, and biased towards silence. A tab is consulted; a notification arrives whether or not you wanted it, and the costs are asymmetric -- a missed finding waits patiently, a wrong nudge trains you to ignore all of them. Medium severity and $0.50 minimum, not already reported unless it has recurred since, and at most one nudge every two hours. A local model, if one is running, can veto a finding as not worth interrupting for. It can never promote one that did not clear the bar, is never consulted about findings below it, and a model failure keeps the finding rather than silencing it. Findings it rejects are recorded, so it is not asked the same question every turn. Also fixes a latent hazard this would have made routine. OpenShard runs tail recovery, which truncates, so a CLI opening a store the server is mid-append to could cut off the chunk being written -- the reader destroying the writer's data. Added a read-only open that skips recovery and opens O_RDONLY, and pointed every read-only command at it, including `fathom query`, which had the same problem already. The nudge's cost figure counted each finding separately, so a set of them could claim more money than the window ever spent: $12.52 against a window costing $7.94. Now counted once per trace. claude/ ships the skill and the hook snippet with an installer. The snippet is printed rather than applied, because a script should not blindly edit a settings file that may already have hooks in it, and a mangled settings.json silently disables everything in it.
276 lines
9.3 KiB
Go
276 lines
9.3 KiB
Go
package insights
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// surfaceAnalyzer builds an analyzer holding a fixed result, so the surfacing
|
|
// rules can be tested without a store or detectors.
|
|
func surfaceAnalyzer(t *testing.T, at time.Time, ins ...Insight) *Analyzer {
|
|
t.Helper()
|
|
a := &Analyzer{
|
|
crystallize: NewCrystallize(),
|
|
dismissed: map[string]int64{},
|
|
surfacedPath: filepath.Join(t.TempDir(), "surfaced.json"),
|
|
now: func() time.Time { return at },
|
|
}
|
|
a.latest = group(ins, Window{}, nil, at.UnixMicro())
|
|
a.hasRun = true
|
|
return a
|
|
}
|
|
|
|
func finding(id string, sev Severity, cost float64, lastSeen int64) Insight {
|
|
return Insight{
|
|
ID: id, Theme: ThemeWaste, Severity: sev, CostUSD: cost,
|
|
Title: "finding " + id, Occurrences: 5, LastSeen: lastSeen,
|
|
Evidence: []Evidence{{TraceID: "tr-" + id, CostUSD: cost}},
|
|
}
|
|
}
|
|
|
|
var noon = time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
|
|
|
|
func TestSurfaceReportsThenGoesQuiet(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("a", SevHigh, 2.00, noon.Add(-time.Hour).UnixMicro()))
|
|
opts := DefaultSurfaceOptions()
|
|
|
|
first := a.SurfaceNew(opts)
|
|
if first.Message == "" {
|
|
t.Fatalf("nothing surfaced on the first run: %s", first.Suppressed)
|
|
}
|
|
if !strings.Contains(first.Message, "/insights") {
|
|
t.Errorf("message does not say where to look: %q", first.Message)
|
|
}
|
|
|
|
// The same finding must not be reported again. A notification repeating
|
|
// itself every turn is how the whole feature gets switched off.
|
|
a.now = func() time.Time { return noon.Add(9 * time.Hour) } // past the cooldown
|
|
second := a.SurfaceNew(opts)
|
|
if second.Message != "" {
|
|
t.Errorf("the same finding was reported twice: %q", second.Message)
|
|
}
|
|
}
|
|
|
|
// TestRecurrenceMakesAFindingNewAgain: a habit you were told about and then kept
|
|
// doing is a stronger signal than the first occurrence was, so it is allowed to
|
|
// speak up once more.
|
|
func TestRecurrenceMakesAFindingNewAgain(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("a", SevHigh, 2.00, noon.Add(-time.Hour).UnixMicro()))
|
|
opts := DefaultSurfaceOptions()
|
|
|
|
if a.SurfaceNew(opts).Message == "" {
|
|
t.Fatal("nothing surfaced on the first run")
|
|
}
|
|
|
|
later := noon.Add(9 * time.Hour)
|
|
a.now = func() time.Time { return later }
|
|
// The pattern happened again after it was reported.
|
|
a.latest = group([]Insight{finding("a", SevHigh, 3.00, later.Add(-time.Minute).UnixMicro())},
|
|
Window{}, nil, later.UnixMicro())
|
|
|
|
if got := a.SurfaceNew(opts); got.Message == "" {
|
|
t.Errorf("a recurrence was not reported: %s", got.Suppressed)
|
|
}
|
|
}
|
|
|
|
func TestCooldownSuppressesASecondNudge(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon,
|
|
finding("a", SevHigh, 2.00, noon.Add(-time.Hour).UnixMicro()),
|
|
)
|
|
opts := DefaultSurfaceOptions()
|
|
if a.SurfaceNew(opts).Message == "" {
|
|
t.Fatal("nothing surfaced on the first run")
|
|
}
|
|
|
|
// A different finding appears ten minutes later. It is genuinely new, but
|
|
// two notifications inside the cooldown is the noise this guards against.
|
|
a.now = func() time.Time { return noon.Add(10 * time.Minute) }
|
|
a.latest = group([]Insight{finding("b", SevHigh, 9.00, noon.UnixMicro())},
|
|
Window{}, nil, noon.UnixMicro())
|
|
|
|
got := a.SurfaceNew(opts)
|
|
if got.Message != "" {
|
|
t.Errorf("nudged again inside the cooldown: %q", got.Message)
|
|
}
|
|
if !strings.Contains(got.Suppressed, "cooldown") {
|
|
t.Errorf("suppression reason should name the cooldown: %q", got.Suppressed)
|
|
}
|
|
}
|
|
|
|
func TestBelowTheBarIsNeverSurfaced(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon,
|
|
finding("cheap", SevHigh, 0.01, noon.UnixMicro()), // above severity, below cost
|
|
finding("minor", SevLow, 50.00, noon.UnixMicro()), // above cost, below severity
|
|
)
|
|
got := a.SurfaceNew(DefaultSurfaceOptions())
|
|
if got.Message != "" {
|
|
t.Errorf("surfaced something below the bar: %q", got.Message)
|
|
}
|
|
if !strings.Contains(got.Suppressed, "below") {
|
|
t.Errorf("suppression reason = %q", got.Suppressed)
|
|
}
|
|
}
|
|
|
|
func TestNoAnalysisYetIsNotAFinding(t *testing.T) {
|
|
a := &Analyzer{crystallize: NewCrystallize(), now: func() time.Time { return noon }}
|
|
got := a.SurfaceNew(DefaultSurfaceOptions())
|
|
if got.Message != "" {
|
|
t.Errorf("surfaced something before any analysis ran: %q", got.Message)
|
|
}
|
|
}
|
|
|
|
// --- the local model's veto ---
|
|
|
|
type fakeJudge struct {
|
|
worth bool
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeJudge) Worth(ctx context.Context, in JudgeInput) (bool, string, error) {
|
|
f.calls++
|
|
return f.worth, "because", f.err
|
|
}
|
|
|
|
func TestModelCanVetoANudge(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("a", SevHigh, 9.00, noon.UnixMicro()))
|
|
a.judge = &fakeJudge{worth: false}
|
|
a.status = LocalStatus{Available: true, Enabled: true}
|
|
|
|
got := a.SurfaceNew(DefaultSurfaceOptions())
|
|
if got.Message != "" {
|
|
t.Errorf("the model said no and it was surfaced anyway: %q", got.Message)
|
|
}
|
|
if !strings.Contains(got.Suppressed, "local model") {
|
|
t.Errorf("suppression reason = %q", got.Suppressed)
|
|
}
|
|
}
|
|
|
|
// TestModelCannotPromoteBelowTheBar is the line the model is not allowed to
|
|
// cross in the other direction. It may quiet a finding that cleared the
|
|
// thresholds; it may never raise one that did not.
|
|
func TestModelCannotPromoteBelowTheBar(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("tiny", SevLow, 0.001, noon.UnixMicro()))
|
|
judge := &fakeJudge{worth: true}
|
|
a.judge = judge
|
|
a.status = LocalStatus{Available: true, Enabled: true}
|
|
|
|
if got := a.SurfaceNew(DefaultSurfaceOptions()); got.Message != "" {
|
|
t.Errorf("a finding below the bar was promoted by the model: %q", got.Message)
|
|
}
|
|
if judge.calls != 0 {
|
|
t.Errorf("the model was consulted about %d findings that never cleared the bar", judge.calls)
|
|
}
|
|
}
|
|
|
|
// TestModelFailureKeepsTheFinding: a slow or broken model must not be able to
|
|
// silence something that cleared every numeric bar.
|
|
func TestModelFailureKeepsTheFinding(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("a", SevHigh, 9.00, noon.UnixMicro()))
|
|
a.judge = &fakeJudge{err: errors.New("connection refused")}
|
|
a.status = LocalStatus{Available: true, Enabled: true}
|
|
|
|
if got := a.SurfaceNew(DefaultSurfaceOptions()); got.Message == "" {
|
|
t.Errorf("a model failure silenced a finding: %s", got.Suppressed)
|
|
}
|
|
}
|
|
|
|
// TestVetoedFindingsAreRecorded: having asked the model once and been told no,
|
|
// asking again about the same findings on every turn would burn time to reach
|
|
// the same answer.
|
|
func TestVetoedFindingsAreRecorded(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("a", SevHigh, 9.00, noon.Add(-time.Hour).UnixMicro()))
|
|
judge := &fakeJudge{worth: false}
|
|
a.judge = judge
|
|
a.status = LocalStatus{Available: true, Enabled: true}
|
|
|
|
a.SurfaceNew(DefaultSurfaceOptions())
|
|
if judge.calls != 1 {
|
|
t.Fatalf("model consulted %d times on the first run", judge.calls)
|
|
}
|
|
|
|
a.now = func() time.Time { return noon.Add(9 * time.Hour) }
|
|
a.SurfaceNew(DefaultSurfaceOptions())
|
|
if judge.calls != 1 {
|
|
t.Errorf("model consulted again about a finding it already rejected (%d calls)", judge.calls)
|
|
}
|
|
}
|
|
|
|
func TestResetMakesEverythingNewAgain(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("a", SevHigh, 2.00, noon.Add(-time.Hour).UnixMicro()))
|
|
opts := DefaultSurfaceOptions()
|
|
|
|
if a.SurfaceNew(opts).Message == "" {
|
|
t.Fatal("nothing surfaced on the first run")
|
|
}
|
|
a.ResetSurfaced()
|
|
|
|
a.now = func() time.Time { return noon.Add(9 * time.Hour) }
|
|
if got := a.SurfaceNew(opts); got.Message == "" {
|
|
t.Errorf("after a reset the finding was still suppressed: %s", got.Suppressed)
|
|
}
|
|
}
|
|
|
|
// TestSurfacedStatePersists: the hook runs as a fresh process every turn, so
|
|
// state that lived only in memory would report the same finding forever.
|
|
func TestSurfacedStatePersists(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "surfaced.json")
|
|
|
|
mk := func(at time.Time) *Analyzer {
|
|
a := &Analyzer{
|
|
crystallize: NewCrystallize(), dismissed: map[string]int64{},
|
|
surfacedPath: path, now: func() time.Time { return at },
|
|
}
|
|
a.latest = group([]Insight{finding("a", SevHigh, 2.00, noon.Add(-time.Hour).UnixMicro())},
|
|
Window{}, nil, at.UnixMicro())
|
|
a.hasRun = true
|
|
return a
|
|
}
|
|
|
|
if mk(noon).SurfaceNew(DefaultSurfaceOptions()).Message == "" {
|
|
t.Fatal("nothing surfaced on the first run")
|
|
}
|
|
// A different process, well past the cooldown.
|
|
if got := mk(noon.Add(9 * time.Hour)).SurfaceNew(DefaultSurfaceOptions()); got.Message != "" {
|
|
t.Errorf("a new process re-reported a finding already surfaced: %q", got.Message)
|
|
}
|
|
}
|
|
|
|
func TestNudgeMessageNamesOneFinding(t *testing.T) {
|
|
a := surfaceAnalyzer(t, noon, finding("a", SevHigh, 2.00, noon.UnixMicro()))
|
|
got := a.SurfaceNew(DefaultSurfaceOptions())
|
|
if !strings.Contains(got.Message, "finding a") {
|
|
t.Errorf("a single finding should be named in the message: %q", got.Message)
|
|
}
|
|
|
|
b := surfaceAnalyzer(t, noon,
|
|
finding("a", SevHigh, 2.00, noon.UnixMicro()),
|
|
finding("b", SevHigh, 3.00, noon.UnixMicro()),
|
|
)
|
|
multi := b.SurfaceNew(DefaultSurfaceOptions())
|
|
if !strings.Contains(multi.Message, "2 new findings") {
|
|
t.Errorf("several findings should be counted: %q", multi.Message)
|
|
}
|
|
// $2 + $3 across two distinct traces.
|
|
if !strings.Contains(multi.Message, "$5.00") {
|
|
t.Errorf("message should total the covered spend: %q", multi.Message)
|
|
}
|
|
}
|
|
|
|
func TestSeverityRank(t *testing.T) {
|
|
if severityRank(SevHigh) <= severityRank(SevMedium) ||
|
|
severityRank(SevMedium) <= severityRank(SevLow) {
|
|
t.Error("severity must rank high > medium > low")
|
|
}
|
|
if severityRank(Severity("nonsense")) != 0 {
|
|
t.Error("an unknown severity must rank below everything, not above it")
|
|
}
|
|
_ = fmt.Sprint
|
|
}
|