feat(brain): surface an aging review queue in brain_context (#82)
CI / Lint / Test / Vet (push) Successful in 30s
CI / Mirror to GitHub (push) Successful in 4s

brain_pending existed the whole time four real items sat unreviewed from
2026-04-23 to 2026-07-15. The tool was never the gap — nothing ever
prompted anyone to call it. The system should be what remembers the queue
exists.

Option 2 from the issue: ride along with brain_context, which sessions
already call, so noticing an aging note costs nobody a decision to go
looking. No scheduled job, no ntfy wiring, nothing new to keep running.

Threshold-gated at 14 days, and silent below it — a notice on every
context call is one people learn to skip, which reproduces the original
failure with extra steps. It reports the whole queue once it speaks: the
threshold decides whether to talk, not what to count.

It never promotes, rejects or reorders. Age is a reason to ask a human,
not a verdict; an old note may be exactly right to promote once someone
looks. A test asserts the queue is untouched and that the payload carries
a hint rather than an action.

An unreadable raw/ dir degrades to silence — brain_context is a hot path
and must not fail because the review queue is broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
This commit is contained in:
2026-08-18 00:37:48 +02:00
co-authored by Claude Opus 5
parent 4e6fc7aeee
commit 3e537bd582
3 changed files with 200 additions and 1 deletions
+76
View File
@@ -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",
}
}
@@ -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"])
}
+9 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/search" "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] 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 // truncateExcerpt clamps an already-stripped excerpt to maxLen characters