feat(brain): add brain_reject — the missing third option for raw/ notes
hyperguild#81. brain_pending lists brain/raw/ notes awaiting review; brain_promote moves one into the wiki. There was no way to say "reviewed, not worth promoting" — the only options were promote it or let it sit forever. Live consequence, confirmed this session: the pending queue has grown to 11 items, the oldest 152 days old, several of which are generic web scrapes nobody is ever going to promote but nobody could clear either except an un-audited rm bypassing the tool surface entirely. RejectNote (internal/api/promote.go) mirrors PromoteNote's exact safety shape: same basename-only filename validation, same write-then-delete ordering (the source survives any write failure), same collision check (never silently overwrite an existing rejected note under the same name). Stamps rejected_at (+ reject_reason if given) into frontmatter, moves brain/raw/<filename> -> brain/raw/rejected/<filename>. No index rebuild or auto-tunnel -- a rejected note never entered the wiki, so there's nothing to index. raw/rejected/ is a subdirectory ListPending's non-recursive os.ReadDir never walks into, so a rejected note is never re-surfaced as pending -- the human decision, once made, stays made. brain_reject wired into the MCP tool list and dispatch, mirroring brain_promote's schema shape (filename required, reason optional). Human-invoked only, same as promote -- never called automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,14 @@ func (s *Server) tools() []map[string]any {
|
||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_reject",
|
||||
"description": "Reject a brain/raw/ note: stamps rejected_at (+ reject_reason if given) into frontmatter and moves it to brain/raw/rejected/ — never deleted outright, and never re-surfaced by brain_pending. The third option alongside promote/leave-pending; a human decision, never automatic. Returns {path}.",
|
||||
"inputSchema": schema([]string{"filename"}, map[string]any{
|
||||
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
||||
"reason": str("optional; why this isn't worth promoting"),
|
||||
}),
|
||||
},
|
||||
noteToolDescriptors()[0],
|
||||
noteToolDescriptors()[1],
|
||||
{
|
||||
@@ -389,6 +397,26 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
type brainRejectArgs struct {
|
||||
Filename string `json:"filename"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
||||
// frontmatter-stamp + move to raw/rejected/). No graph indexing — a rejected
|
||||
// note never entered the wiki, so there's nothing to index.
|
||||
func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainRejectArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||
// logs failures but never propagates them — the underlying write/ingest
|
||||
// has already succeeded and the graph is an augmentation, not a
|
||||
|
||||
@@ -396,3 +396,38 @@ func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
||||
assert.NoError(t, srcErr, "source untouched on validation error")
|
||||
}
|
||||
|
||||
func TestBrainRejectMovesToRejectedSubdir(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-04-23-scrape.md"),
|
||||
[]byte("---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Scrape\n\nbody\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_reject", map[string]any{
|
||||
"filename": "2026-04-23-scrape.md", "reason": "generic scrape, not homelab-relevant",
|
||||
})
|
||||
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "raw/rejected/2026-04-23-scrape.md")
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, "raw/rejected/2026-04-23-scrape.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "reject_reason: generic scrape, not homelab-relevant")
|
||||
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "2026-04-23-scrape.md"))
|
||||
assert.True(t, os.IsNotExist(srcErr), "source removed from raw/")
|
||||
|
||||
// A rejected note must not still show up as pending.
|
||||
pendingResp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
pendingText := pendingResp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, pendingText, `"pending":[]`)
|
||||
}
|
||||
|
||||
func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
@@ -275,6 +275,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
||||
return s.brainPending(ctx, args)
|
||||
case "brain_promote":
|
||||
return s.brainPromote(ctx, args)
|
||||
case "brain_reject":
|
||||
return s.brainReject(ctx, args)
|
||||
case "capture":
|
||||
return s.brainCapture(ctx, args)
|
||||
case "brain_read":
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestServerToolsList(t *testing.T) {
|
||||
}
|
||||
assert.ElementsMatch(t, []string{
|
||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||
"brain_pending", "brain_promote",
|
||||
"brain_pending", "brain_promote", "brain_reject",
|
||||
"brain_read", "brain_edit",
|
||||
"brain_tunnel",
|
||||
"brain_ingest_raw", "brain_ingest",
|
||||
|
||||
Reference in New Issue
Block a user