diff --git a/ingestion/internal/mcp/pending_nudge.go b/ingestion/internal/mcp/pending_nudge.go new file mode 100644 index 0000000..9005486 --- /dev/null +++ b/ingestion/internal/mcp/pending_nudge.go @@ -0,0 +1,76 @@ +package mcp + +import ( + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" +) + +// pendingNudgeThreshold is how old a raw/ note has to be before brain_context +// mentions the queue. Two weeks: long enough that a note written this sprint +// does not nag, short enough that four items cannot sit unnoticed for three +// months again (#82). +const pendingNudgeThreshold = 14 * 24 * time.Hour + +// PendingNudge is the in-session reminder that the promote/reject queue has +// items waiting. It is advisory: a count, an age, and how to look. +type PendingNudge struct { + Pending int `json:"pending"` // everything in the queue + Aging int `json:"aging"` // those past the threshold + Oldest string `json:"oldest"` // filename of the oldest pending note + OldestDays int `json:"oldest_days"` // its age, in whole days + Hint string `json:"hint"` +} + +// pendingNudge summarises the raw/ review queue, or returns nil when there is +// nothing worth saying. +// +// The trigger is the missing half of the queue (#82): brain_pending has always +// existed, and four real items still sat unreviewed from 2026-04-23 to +// 2026-07-15 because nothing ever prompted anyone to call it. The system +// should be what remembers the queue exists. +// +// Threshold-gated on purpose. A notice on every context call is one people +// learn to skip, which reproduces the original failure with extra steps. +// +// It never promotes, rejects, or reorders anything. Age is a reason to ask a +// human, not a verdict — an old note may be exactly right to promote once +// someone finally looks at it. +func pendingNudge(brainDir string, now time.Time, threshold time.Duration) *PendingNudge { + pending, err := api.ListPending(brainDir) + if err != nil || len(pending) == 0 { + // brain_context is a hot path; a broken queue degrades to silence + // rather than failing the call that asked for project context. + return nil + } + + var aging int + var oldestName string + var oldestAge time.Duration + + for _, p := range pending { + created, perr := time.Parse(time.RFC3339, p.CreatedAt) + if perr != nil { + continue // an unparseable timestamp is not evidence of age + } + age := now.Sub(created) + if age >= threshold { + aging++ + } + if age > oldestAge { + oldestAge, oldestName = age, p.Filename + } + } + + if aging == 0 { + return nil + } + + return &PendingNudge{ + Pending: len(pending), + Aging: aging, + Oldest: oldestName, + OldestDays: int(oldestAge.Hours() / 24), + Hint: "call brain_pending to review; nothing is promoted or rejected automatically", + } +} diff --git a/ingestion/internal/mcp/pending_nudge_test.go b/ingestion/internal/mcp/pending_nudge_test.go new file mode 100644 index 0000000..8aea671 --- /dev/null +++ b/ingestion/internal/mcp/pending_nudge_test.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writePending drops a raw/ note whose created_at is age old. +func writePending(t *testing.T, brainDir, name string, age time.Duration, now time.Time) { + t.Helper() + raw := filepath.Join(brainDir, "raw") + require.NoError(t, os.MkdirAll(raw, 0o755)) + body := "---\ncreated_at: " + now.Add(-age).UTC().Format(time.RFC3339) + "\n---\n\nnote body\n" + require.NoError(t, os.WriteFile(filepath.Join(raw, name), []byte(body), 0o644)) +} + +func TestPendingNudge_SilentWhenNothingIsPending(t *testing.T) { + // No queue, no nudge. A notice that fires every session is one people + // learn to skip, which is how the queue got ignored for three months in + // the first place (#82). + got := pendingNudge(t.TempDir(), time.Now(), 14*24*time.Hour) + assert.Nil(t, got) +} + +func TestPendingNudge_SilentWhileEverythingIsBelowTheThreshold(t *testing.T) { + now := time.Now() + dir := t.TempDir() + writePending(t, dir, "fresh.md", 2*24*time.Hour, now) + + got := pendingNudge(dir, now, 14*24*time.Hour) + assert.Nil(t, got, "a two-day-old note is not aging") +} + +func TestPendingNudge_ReportsCountAndOldestAgeOnceSomethingAges(t *testing.T) { + now := time.Now() + dir := t.TempDir() + writePending(t, dir, "old.md", 100*24*time.Hour, now) + writePending(t, dir, "older.md", 120*24*time.Hour, now) + writePending(t, dir, "fresh.md", 1*24*time.Hour, now) + + got := pendingNudge(dir, now, 14*24*time.Hour) + require.NotNil(t, got) + + // All three are pending; the threshold decides whether to speak, not + // what to count. Someone opening the queue sees the whole queue. + assert.Equal(t, 3, got.Pending) + assert.Equal(t, 2, got.Aging) + assert.Equal(t, 120, got.OldestDays) + assert.Equal(t, "older.md", got.Oldest) +} + +func TestPendingNudge_NeverDecidesAnything(t *testing.T) { + // Aging is a trigger to ask a human, never a verdict. The nudge must + // carry no action and must leave the queue exactly as it found it. + now := time.Now() + dir := t.TempDir() + writePending(t, dir, "ancient.md", 365*24*time.Hour, now) + + before, err := os.ReadDir(filepath.Join(dir, "raw")) + require.NoError(t, err) + + got := pendingNudge(dir, now, 14*24*time.Hour) + require.NotNil(t, got) + + after, err := os.ReadDir(filepath.Join(dir, "raw")) + require.NoError(t, err) + assert.Len(t, after, len(before), "the nudge must not promote or reject anything") + assert.Contains(t, got.Hint, "brain_pending", "the nudge should say how to look, not act") +} + +func TestPendingNudge_SurvivesAnUnreadableQueue(t *testing.T) { + // brain_context is a hot path; a broken raw/ dir must degrade to silence + // rather than failing the whole context call. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "raw"), []byte("not a directory"), 0o644)) + + assert.NotPanics(t, func() { + assert.Nil(t, pendingNudge(dir, time.Now(), 14*24*time.Hour)) + }) +} + +func TestBrainContext_CarriesTheNudgeWithoutFailingWhenTheQueueIsEmpty(t *testing.T) { + // The wiring, not the summary: a nudge that never reaches a caller is the + // same as no nudge, and brain_context must still work with no queue. + dir := t.TempDir() + srv := NewServer(dir, nil, nil, nil) + + raw, err := srv.brainContext(context.Background(), + json.RawMessage(`{"project_root":"/home/mathias/dev/AGENTS/hyperguild"}`)) + require.NoError(t, err) + + var empty map[string]any + require.NoError(t, json.Unmarshal(raw, &empty)) + assert.NotContains(t, empty, "pending_review", "silence when nothing is aging") + + writePending(t, dir, "ancient.md", 200*24*time.Hour, time.Now()) + + raw, err = srv.brainContext(context.Background(), + json.RawMessage(`{"project_root":"/home/mathias/dev/AGENTS/hyperguild"}`)) + require.NoError(t, err) + + var withQueue map[string]any + require.NoError(t, json.Unmarshal(raw, &withQueue)) + nudge, ok := withQueue["pending_review"].(map[string]any) + require.True(t, ok, "the nudge must reach the caller: %v", withQueue) + assert.Equal(t, float64(1), nudge["aging"]) + assert.Equal(t, "ancient.md", nudge["oldest"]) +} diff --git a/ingestion/internal/mcp/tools_context.go b/ingestion/internal/mcp/tools_context.go index 14c7f9d..9ccfcec 100644 --- a/ingestion/internal/mcp/tools_context.go +++ b/ingestion/internal/mcp/tools_context.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "strings" + "time" "github.com/mathiasbq/hyperguild/ingestion/internal/search" ) @@ -155,7 +156,14 @@ func (s *Server) brainContext(ctx context.Context, args json.RawMessage) (json.R entries = entries[:limit] } - return json.Marshal(map[string]any{"entries": entries}) + out := map[string]any{"entries": entries} + // The queue nudge rides along with a call the session already makes, so + // noticing an aging note costs nobody a decision to go looking (#82). + // Advisory only, and absent entirely when there is nothing to say. + if n := pendingNudge(s.brainDir, time.Now(), pendingNudgeThreshold); n != nil { + out["pending_review"] = n + } + return json.Marshal(out) } // truncateExcerpt clamps an already-stripped excerpt to maxLen characters