diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index d3a3f18..98592a2 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -369,6 +369,8 @@ func main() { mux.HandleFunc("POST /ingest-path", h.IngestPath) mux.HandleFunc("POST /ingest-raw", h.IngestRaw) mux.HandleFunc("POST /backfill-refs", h.BackfillRefs) + mux.HandleFunc("GET /pending", h.Pending) + mux.HandleFunc("POST /promote", h.Promote) mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) mux.HandleFunc("GET /pass-rate", h.PassRate) jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE")) diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 8a750a1..ecf33e4 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/extract" @@ -81,6 +82,21 @@ func (s *Server) tools() []map[string]any { "path": str("brain-relative path to the note; equivalent to id"), }), }, + { + "name": "brain_pending", + "description": "List notes in brain/raw/ awaiting human promotion to the wiki, oldest-first. Returns filename, created_at, size_bytes, excerpt. The human-review queue complement to brain_promote.", + "inputSchema": schema([]string{}, map[string]any{}), + }, + { + "name": "brain_promote", + "description": "Promote a brain/raw/ note into brain/wiki///: rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at + custom fields), deletes the source, rebuilds the wing index, runs auto-tunnel. Errors (without touching the fs) on invalid hall or a slug collision. Returns {path}.", + "inputSchema": schema([]string{"filename", "wing", "hall"}, map[string]any{ + "filename": str("basename in brain/raw/, e.g. 2026-06-01-lejpa-decision.md"), + "wing": str("target wing, e.g. jepa-fx"), + "hall": enum("target hall", halls...), + "slug": str("optional target slug; defaults to filename minus date prefix"), + }), + }, { "name": "brain_tunnel", "description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.", @@ -321,6 +337,40 @@ func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMe }) } +// brainPending lists the raw/ review queue (oldest-first). +func (s *Server) brainPending(_ context.Context, _ json.RawMessage) (json.RawMessage, error) { + pending, err := api.ListPending(s.brainDir) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{"pending": pending}) +} + +type brainPromoteArgs struct { + Filename string `json:"filename"` + Wing string `json:"wing"` + Hall string `json:"hall"` + Slug string `json:"slug,omitempty"` +} + +// brainPromote moves a raw/ note into the structured wiki (frontmatter +// rewrite + index + auto-tunnel, all owned by api.PromoteNote) and then +// re-indexes it into the graph. The human-facing complement to brain_write. +func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainPromoteArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{ + Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, + }) + if err != nil { + return nil, err + } + s.indexInGraph(ctx, "brain_promote", relPath) + 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 diff --git a/ingestion/internal/mcp/handlers_test.go b/ingestion/internal/mcp/handlers_test.go index 9da1fca..7333a47 100644 --- a/ingestion/internal/mcp/handlers_test.go +++ b/ingestion/internal/mcp/handlers_test.go @@ -332,3 +332,61 @@ func TestSessionLogRequiresSessionID(t *testing.T) { resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"}) require.NotNil(t, resp["error"]) } + +func TestBrainPendingListsRaw(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-06-01-x.md"), + []byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\npending body\n"), 0o644)) + srv := mcp.NewServer(brainDir, nil, nil, nil) + + resp := toolCall(t, srv, "brain_pending", map[string]any{}) + require.Nil(t, resp["error"]) + text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + assert.Contains(t, text, "2026-06-01-x.md") + assert.Contains(t, text, "pending body") +} + +func TestBrainPendingEmpty(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) + resp := toolCall(t, srv, "brain_pending", map[string]any{}) + require.Nil(t, resp["error"]) + text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + assert.Contains(t, text, `"pending":[]`) +} + +func TestBrainPromoteMovesToWiki(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-06-01-decision.md"), + []byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\n# D\n\nbody\n"), 0o644)) + srv := mcp.NewServer(brainDir, nil, nil, nil) + + resp := toolCall(t, srv, "brain_promote", map[string]any{ + "filename": "2026-06-01-decision.md", "wing": "jepa-fx", "hall": "decisions", + }) + 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, "wiki/jepa-fx/decisions/decision.md") + + _, err := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/decisions/decision.md")) + require.NoError(t, err) + _, srcErr := os.Stat(filepath.Join(raw, "2026-06-01-decision.md")) + assert.True(t, os.IsNotExist(srcErr), "source removed") +} + +func TestBrainPromoteInvalidHallErrors(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, "x.md"), []byte("body\n"), 0o644)) + srv := mcp.NewServer(brainDir, nil, nil, nil) + resp := toolCall(t, srv, "brain_promote", map[string]any{ + "filename": "x.md", "wing": "a", "hall": "garbage", + }) + require.NotNil(t, resp["error"]) + _, srcErr := os.Stat(filepath.Join(raw, "x.md")) + assert.NoError(t, srcErr, "source untouched on validation error") +} diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index aa33734..fce7876 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -1,8 +1,9 @@ // Package mcp implements an MCP HTTP handler for the ingestion service. // Exposed tools: brain_query, brain_write, brain_update, brain_get, -// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw, -// brain_answer, brain_classify, brain_graph, brain_context, session_log, -// and capture (the #55 relay tool, registered only when WithCapture is set). +// brain_pending, brain_promote, brain_index, brain_tunnel, brain_ingest, +// brain_ingest_raw, brain_answer, brain_classify, brain_graph, +// brain_context, session_log, and capture (the #55 relay tool, registered +// only when WithCapture is set). package mcp import ( @@ -263,6 +264,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa return s.brainUpdate(ctx, args) case "brain_get": return s.brainGet(ctx, args) + case "brain_pending": + return s.brainPending(ctx, args) + case "brain_promote": + return s.brainPromote(ctx, args) case "capture": return s.brainCapture(ctx, args) case "brain_index": diff --git a/ingestion/internal/mcp/server_test.go b/ingestion/internal/mcp/server_test.go index 06f4bfb..da0aa15 100644 --- a/ingestion/internal/mcp/server_test.go +++ b/ingestion/internal/mcp/server_test.go @@ -58,6 +58,7 @@ func TestServerToolsList(t *testing.T) { } assert.ElementsMatch(t, []string{ "brain_query", "brain_write", "brain_update", "brain_get", + "brain_pending", "brain_promote", "brain_index", "brain_tunnel", "brain_ingest_raw", "brain_ingest", "brain_answer", "brain_classify", "brain_graph", "brain_context",