Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f8fb9c138 | ||
|
|
d39a18dd69 | ||
|
|
5288554338 | ||
|
|
2368564523 | ||
|
|
0e28b2125b | ||
|
|
723dab51ae | ||
|
|
06e21c019e | ||
|
|
76514215f4 | ||
|
|
7cf5bc221d | ||
|
|
f78a5474a5 | ||
|
|
c307b72bd5 |
@@ -410,13 +410,22 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
auditSink := buildAuditSink(ctx, brainDir, logger)
|
||||
// The Gitea client also satisfies SummaryWriter (#66): session
|
||||
// summaries are written to mathias/ai-sessions over the same API
|
||||
// token. nil only if a future tracker impl lacks file writes.
|
||||
summaryWriter, _ := tracker.(capture.SummaryWriter)
|
||||
captureSvc := capture.NewService(
|
||||
mcpSrv.BrainStore(), tracker, nil, classCfg, auditSink)
|
||||
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink)
|
||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli",
|
||||
capturehttp.NewOriginResolver(sovereign))
|
||||
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||
mux.Handle("POST /capture", captureH)
|
||||
logger.Info("capture endpoint enabled", "sovereign_principals", len(sovereign))
|
||||
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
||||
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||
// WithCapture mutates the same instance, so the tool appears live.
|
||||
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
|
||||
} else {
|
||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -90,19 +91,22 @@ type summaryBody struct {
|
||||
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||
// result to an HTTP status.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
principal, viaStatic, ok := h.authenticate(r)
|
||||
principal, viaStatic, ok := Authenticate(r, h.staticToken, h.staticPrincipal, h.validator)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req request
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read body"})
|
||||
return
|
||||
}
|
||||
in, err := DecodeRequest(body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
in := req.toInput()
|
||||
// Principal and origin are server-derived — overwrite anything the
|
||||
// caller may have tried to put in the body.
|
||||
in.Context.Principal = principal
|
||||
@@ -125,26 +129,39 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, statusFor(rec), rec)
|
||||
}
|
||||
|
||||
// authenticate mirrors the chassis Bearer precedence (static wins, then
|
||||
// JWT) but returns the resolved principal and whether the static path was
|
||||
// taken — the chassis middleware hides both, and capture needs them to
|
||||
// derive the origin.
|
||||
func (h *Handler) authenticate(r *http.Request) (principal string, viaStatic, ok bool) {
|
||||
// Authenticate mirrors the chassis Bearer precedence (static token wins,
|
||||
// then Dex JWT) and returns the resolved principal plus whether the static
|
||||
// path was taken — the chassis middleware hides both, and capture (REST or
|
||||
// MCP) needs them to derive the trust-zone origin. ok is false when no
|
||||
// credential matched.
|
||||
func Authenticate(r *http.Request, staticToken, staticPrincipal string, validator Validator) (principal string, viaStatic, ok bool) {
|
||||
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !found || raw == "" {
|
||||
return "", false, false
|
||||
}
|
||||
if h.staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(h.staticToken)) == 1 {
|
||||
return h.staticPrincipal, true, true
|
||||
if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 {
|
||||
return staticPrincipal, true, true
|
||||
}
|
||||
if h.validator != nil {
|
||||
if sub, err := h.validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||
if validator != nil {
|
||||
if sub, err := validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||
return sub, false, true
|
||||
}
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// DecodeRequest parses a capture request body into a CaptureInput. Shared
|
||||
// by the REST adapter and the MCP capture tool so the wire shape has one
|
||||
// definition. Principal and Origin are NOT set here — the caller sets them
|
||||
// from the authenticated identity.
|
||||
func DecodeRequest(data []byte) (capture.CaptureInput, error) {
|
||||
var b request
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
return capture.CaptureInput{}, err
|
||||
}
|
||||
return b.toInput(), nil
|
||||
}
|
||||
|
||||
func (b request) toInput() capture.CaptureInput {
|
||||
in := capture.CaptureInput{
|
||||
Context: capture.CaptureContext{
|
||||
|
||||
@@ -12,6 +12,7 @@ package gitea
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -93,37 +94,105 @@ func (c *Client) CloseIssue(ctx context.Context, repo string, number int, commen
|
||||
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||
}
|
||||
|
||||
// do performs a JSON request against the Gitea API and decodes the
|
||||
// response into out. Errors carry the status and a truncated body for
|
||||
// diagnosis but never the token.
|
||||
func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error {
|
||||
reqBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(reqBody))
|
||||
// WriteFile creates or updates a file in repo at path via the Gitea
|
||||
// contents API — the SummaryWriter port (#66). It upserts: a GET resolves
|
||||
// the current blob sha (if any) so an existing file is updated rather than
|
||||
// rejected (the richer-fidelity-supersedes rule for re-captured sessions).
|
||||
// Owner is the fixed const, like every other call.
|
||||
func (c *Client) WriteFile(ctx context.Context, repo, path, content string) error {
|
||||
cpath := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
||||
sha, err := c.fileSHA(ctx, cpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
// Gitea's token scheme. Held here only; never logged.
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
payload := map[string]any{
|
||||
"message": "capture: " + path,
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||
}
|
||||
// Gitea contents API: POST creates a new file, PUT updates an existing
|
||||
// one (PUT requires the current sha). Pick by whether the file exists.
|
||||
method := http.MethodPost
|
||||
if sha != "" {
|
||||
method = http.MethodPut
|
||||
payload["sha"] = sha
|
||||
}
|
||||
status, body, err := c.request(ctx, method, cpath, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("gitea %s %s: status %d: %s", method, cpath, status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
// fileSHA returns the current blob sha for a contents path, or "" when the
|
||||
// file does not exist (404). Any other non-2xx is an error.
|
||||
func (c *Client) fileSHA(ctx context.Context, cpath string) (string, error) {
|
||||
status, body, err := c.request(ctx, http.MethodGet, cpath, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
if status == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return "", fmt.Errorf("gitea GET %s: status %d: %s", cpath, status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var meta struct {
|
||||
SHA string `json:"sha"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &meta); err != nil {
|
||||
return "", fmt.Errorf("gitea GET %s: decode: %w", cpath, err)
|
||||
}
|
||||
return meta.SHA, nil
|
||||
}
|
||||
|
||||
// do performs a JSON request against the Gitea API and decodes a 2xx
|
||||
// response into out. Errors carry the status and a truncated body for
|
||||
// diagnosis but never the token.
|
||||
func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error {
|
||||
status, body, err := c.request(ctx, method, path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if out != nil && len(body) > 0 {
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// request is the shared HTTP path: marshals an optional JSON payload,
|
||||
// attaches auth (token only ever in the header), and returns the status +
|
||||
// body so callers can branch on status (e.g. 404) without it being an
|
||||
// error. Never logs the token.
|
||||
func (c *Client) request(ctx context.Context, method, path string, payload any) (int, []byte, error) {
|
||||
var reader io.Reader
|
||||
if payload != nil {
|
||||
reqBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(reqBody)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
@@ -115,3 +115,69 @@ func TestErrorPathDoesNotLeakToken(t *testing.T) {
|
||||
assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message")
|
||||
assert.Contains(t, err.Error(), "500")
|
||||
}
|
||||
|
||||
func TestWriteFileCreatesNewFile(t *testing.T) {
|
||||
var getPath, postPath, postBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
getPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusNotFound) // file does not exist yet
|
||||
case http.MethodPost: // gitea contents API: POST = create
|
||||
postPath = r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
postBody = string(b)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||
default:
|
||||
t.Errorf("create must POST, got %s", r.Method)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(),
|
||||
"ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath)
|
||||
assert.Equal(t, getPath, postPath)
|
||||
// base64 of the content, no sha on create.
|
||||
assert.Contains(t, postBody, "IyBTdW1tYXJ5") // base64("# Summary")
|
||||
assert.NotContains(t, postBody, `"sha"`)
|
||||
}
|
||||
|
||||
func TestWriteFileUpdatesExisting(t *testing.T) {
|
||||
var putBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"sha": "deadbeef"})
|
||||
case http.MethodPut:
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
putBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "new")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, putBody, `"sha":"deadbeef"`, "existing file → update with sha")
|
||||
}
|
||||
|
||||
func TestWriteFileErrorNoTokenLeak(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = w.Write([]byte("bad"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "x")
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), testToken)
|
||||
assert.Contains(t, err.Error(), "422")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (s *Server) tools() []map[string]any {
|
||||
return b
|
||||
}
|
||||
|
||||
return []map[string]any{
|
||||
tools := []map[string]any{
|
||||
{
|
||||
"name": "brain_query",
|
||||
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).",
|
||||
@@ -171,6 +171,13 @@ func (s *Server) tools() []map[string]any {
|
||||
}),
|
||||
},
|
||||
}
|
||||
// The capture relay tool (#55) is advertised only when wired via
|
||||
// WithCapture — MCP-native harnesses (claude.ai, Crush, Pi, LLM Council)
|
||||
// reach capture through it.
|
||||
if s.capture != nil {
|
||||
tools = append(tools, captureToolDescriptor())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
type brainQueryArgs struct {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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.
|
||||
// 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 (
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
@@ -50,6 +52,19 @@ type Server struct {
|
||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
||||
tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53)
|
||||
capture *captureDeps // nil = capture MCP tool disabled (#55 relay)
|
||||
}
|
||||
|
||||
// captureDeps holds what the MCP `capture` tool (the #55 relay door for
|
||||
// MCP-native harnesses like claude.ai) needs: the use-case, the auth bits
|
||||
// to re-derive the caller's principal from the Bearer header (the chassis
|
||||
// middleware gates but discards the principal), and the origin resolver.
|
||||
type captureDeps struct {
|
||||
svc *capture.Service
|
||||
validator capturehttp.Validator
|
||||
staticToken string
|
||||
staticPrincipal string
|
||||
resolver capturehttp.OriginResolver
|
||||
}
|
||||
|
||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||
@@ -123,6 +138,29 @@ func (s *Server) BrainStore() *brainstore.Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// WithCapture enables the MCP `capture` tool (#55) — the relay door for
|
||||
// MCP-native harnesses (claude.ai, Crush, Pi, LLM Council) that cannot run
|
||||
// the use-case in-process. It forwards to the same CaptureService as
|
||||
// POST /capture, deriving the caller's principal + origin from the same
|
||||
// auth credentials that gate /mcp. nil svc leaves the tool unregistered.
|
||||
func (s *Server) WithCapture(svc *capture.Service, validator capturehttp.Validator, staticToken, staticPrincipal string, resolver capturehttp.OriginResolver) *Server {
|
||||
if svc == nil {
|
||||
s.capture = nil
|
||||
return s
|
||||
}
|
||||
if staticPrincipal == "" {
|
||||
staticPrincipal = "local-cli"
|
||||
}
|
||||
s.capture = &captureDeps{
|
||||
svc: svc,
|
||||
validator: validator,
|
||||
staticToken: staticToken,
|
||||
staticPrincipal: staticPrincipal,
|
||||
resolver: resolver,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||
if r.Method == http.MethodGet {
|
||||
@@ -172,7 +210,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
||||
break
|
||||
}
|
||||
out, err := s.handleCall(r.Context(), p.Name, p.Arguments)
|
||||
// Re-derive the authenticated principal from the Bearer header so
|
||||
// the capture tool can compute the trust-zone origin. The request
|
||||
// is already gated by BearerMiddleware; this only recovers the
|
||||
// identity that middleware discards.
|
||||
ctx := r.Context()
|
||||
if s.capture != nil {
|
||||
if principal, viaStatic, ok := capturehttp.Authenticate(
|
||||
r, s.capture.staticToken, s.capture.staticPrincipal, s.capture.validator); ok {
|
||||
ctx = withPrincipal(ctx, principal, viaStatic)
|
||||
}
|
||||
}
|
||||
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
||||
if err != nil {
|
||||
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||
break
|
||||
@@ -214,6 +263,8 @@ 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 "capture":
|
||||
return s.brainCapture(ctx, args)
|
||||
case "brain_index":
|
||||
return s.brainIndex(ctx, args)
|
||||
case "brain_tunnel":
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
)
|
||||
|
||||
// principalKey is the context key under which the authenticated principal
|
||||
// (re-derived in ServeHTTP) is stashed for the capture tool.
|
||||
type principalKeyT struct{}
|
||||
|
||||
var principalKey principalKeyT
|
||||
|
||||
type principalInfo struct {
|
||||
principal string
|
||||
viaStatic bool
|
||||
}
|
||||
|
||||
func withPrincipal(ctx context.Context, principal string, viaStatic bool) context.Context {
|
||||
return context.WithValue(ctx, principalKey, principalInfo{principal: principal, viaStatic: viaStatic})
|
||||
}
|
||||
|
||||
// captureToolDescriptor is the tools/list entry for the capture relay.
|
||||
// Appended only when WithCapture has wired the tool.
|
||||
func captureToolDescriptor() map[string]any {
|
||||
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
insightItem := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": str("the insight body"), "wing": str("brain wing"),
|
||||
"hall": str("brain hall (facts/decisions/failures/hypotheses/sources)"),
|
||||
"supersede_slug": str("optional: slug of a prior note to revise in place instead of creating"),
|
||||
},
|
||||
"required": []string{"text", "wing", "hall"},
|
||||
}
|
||||
ticketItem := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"repo": str("gitea repo (owner is always mathias)"), "action": str("create|close|comment"),
|
||||
"number": map[string]any{"type": "integer", "description": "issue number (close/comment)"},
|
||||
"title": str("issue title (create)"), "body": str("issue/comment body"),
|
||||
},
|
||||
"required": []string{"repo", "action"},
|
||||
}
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"context": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"harness": str("descriptive harness label (telemetry only, never a gate input)"),
|
||||
"session_ref": str("optional session reference"), "fidelity": str("live-capture|transcript-parse|agent-runlog"),
|
||||
"actor": str("acting user/agent"), "classification": str("caller-declared sensitivity: public|internal|confidential"),
|
||||
},
|
||||
},
|
||||
"insights": map[string]any{"type": "array", "items": insightItem},
|
||||
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
||||
"summary": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"title": str("summary title"), "body": str("summary body"),
|
||||
"repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
}},
|
||||
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(schema)
|
||||
return map[string]any{
|
||||
"name": "capture",
|
||||
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets, optional summary → ai-sessions. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
||||
"inputSchema": json.RawMessage(b),
|
||||
}
|
||||
}
|
||||
|
||||
// brainCapture is the MCP capture tool: the #55 relay for MCP-native
|
||||
// harnesses. It re-uses the same CaptureService, principal-derivation, and
|
||||
// origin resolver as POST /capture — only the transport differs. It holds
|
||||
// no state and retains nothing beyond the I5 audit record.
|
||||
func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
if s.capture == nil {
|
||||
return nil, fmt.Errorf("capture tool not configured")
|
||||
}
|
||||
info, ok := ctx.Value(principalKey).(principalInfo)
|
||||
if !ok || info.principal == "" {
|
||||
// No authenticated principal ⇒ cannot derive origin ⇒ cannot gate.
|
||||
return nil, fmt.Errorf("capture requires an authenticated principal")
|
||||
}
|
||||
|
||||
in, err := capturehttp.DecodeRequest(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid capture request: %w", err)
|
||||
}
|
||||
// Principal and origin are server-derived — never taken from the body.
|
||||
in.Context.Principal = info.principal
|
||||
in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic)
|
||||
|
||||
rec, err := s.capture.svc.Capture(ctx, in)
|
||||
if err != nil {
|
||||
// Surface I1/I5 refusals and validation failures verbatim; errors.Is
|
||||
// markers (ErrSovereigntyRefused / ErrAuditUnavailable) ride in the message.
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(rec)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const capStaticTok = "cap-static-tok"
|
||||
|
||||
type capFakeTracker struct{}
|
||||
|
||||
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
type capFakeValidator struct {
|
||||
subject string
|
||||
err error
|
||||
}
|
||||
|
||||
func (v capFakeValidator) Validate(context.Context, string) (string, error) {
|
||||
return v.subject, v.err
|
||||
}
|
||||
|
||||
func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []string) (*mcp.Server, string) {
|
||||
t.Helper()
|
||||
brainDir := t.TempDir()
|
||||
cfg, err := classification.Load(brainDir)
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, nil, cfg, audit.NewSlogSink(nil))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||
return srv, brainDir
|
||||
}
|
||||
|
||||
func captureCall(t *testing.T, srv http.Handler, authz string, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": "capture", "arguments": args},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCaptureToolListedWhenWired(t *testing.T) {
|
||||
srv, _ := captureServer(t, nil, nil)
|
||||
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
assert.Contains(t, rr.Body.String(), `"capture"`)
|
||||
}
|
||||
|
||||
func TestCaptureToolNotListedByDefault(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil) // no WithCapture
|
||||
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
assert.NotContains(t, rr.Body.String(), `"capture"`)
|
||||
}
|
||||
|
||||
func TestCaptureToolForwardsViaStaticPrincipal(t *testing.T) {
|
||||
srv, brainDir := captureServer(t, nil, nil)
|
||||
resp := captureCall(t, srv, "Bearer "+capStaticTok, map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||
})
|
||||
require.Nil(t, resp["error"], "got error: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
var rec capture.CaptureReceipt
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &rec))
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
assert.True(t, rec.Tickets[0].OK)
|
||||
// Forwarded to the real brain store.
|
||||
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/hyperguild/facts"))
|
||||
require.NoError(t, statErr)
|
||||
}
|
||||
|
||||
func TestCaptureToolRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||
// JWT principal not in the sovereign allowlist ⇒ us-nexus origin.
|
||||
srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil)
|
||||
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty")
|
||||
}
|
||||
|
||||
func TestCaptureToolAllowsConfidentialViaSovereignJWT(t *testing.T) {
|
||||
srv, _ := captureServer(t, capFakeValidator{subject: "koala-cli"}, []string{"koala-cli"})
|
||||
resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{
|
||||
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
assert.Nil(t, resp["error"], "sovereign JWT principal should be allowed: %v", resp["error"])
|
||||
}
|
||||
|
||||
func TestCaptureToolRejectsUnauthenticated(t *testing.T) {
|
||||
srv, _ := captureServer(t, capFakeValidator{err: errors.New("no jwt")}, nil)
|
||||
resp := captureCall(t, srv, "", map[string]any{ // no Authorization
|
||||
"context": map[string]any{"harness": "x", "classification": "internal"},
|
||||
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "authenticated principal")
|
||||
}
|
||||
|
||||
func TestCaptureToolCallerCannotForgeOrigin(t *testing.T) {
|
||||
// Body asserts sovereign harness, but the us-nexus JWT principal governs.
|
||||
srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil)
|
||||
resp := captureCall(t, srv, "Bearer jwt", map[string]any{
|
||||
"context": map[string]any{"harness": "sovereign-soil", "classification": "confidential"},
|
||||
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty")
|
||||
}
|
||||
@@ -7,14 +7,14 @@ description: Disciplined end-of-session closeout for a Claude.ai chat before arc
|
||||
|
||||
Capture a finishing Claude.ai work session into durable storage before the chat is archived and its context is lost. The goal is simple and load-bearing: **after this runs, a fresh session (or another agent) can reconstruct what was decided, what was shipped, and what is still open — without the original chat.**
|
||||
|
||||
This skill is **batch**: one session in, findings out, done. It does not loop or re-read its own fresh output semantically (see Phase 5). Run the phases in order. Stop at any confirmation gate that says STOP.
|
||||
This skill is **batch**: one session in, findings out, done. Run the phases in order. Stop at any confirmation gate that says STOP.
|
||||
|
||||
## Operating constraints (read first)
|
||||
|
||||
- **Gitea owner is always `mathias`.** Never guess another owner.
|
||||
- **Ground-truth at HEAD before acting.** Issue bodies and doc references rot — stale hostnames, retired services, moved endpoints. Before closing/commenting on any issue, `gitea:issue_get` it fresh. Before asserting an infra fact, verify it; do not copy it from memory or from a stale issue body.
|
||||
- **Current infra truths** (verify rather than trust, but these are the known-good baseline): Gitea is `git.d-ma.be` (not `gitea.d-ma.be`). LiteLLM is `http://koala:30401/v1/` (public `https://llm-api.d-ma.be`); piguard runs NGINX Proxy Manager only — never reference `piguard:4000` or `koala:4000`. Identity provider is Authentik (Dex migration complete).
|
||||
- **Side-effects need a confirmation gate.** Closing issues, committing files, and writing to the brain are all real writes. Surface exactly what will happen and get a clear yes before doing it. Reads are free; writes are gated.
|
||||
- **Side-effects need a confirmation gate.** Closing issues and capturing to brain/Gitea/ai-sessions are real writes. Surface exactly what will happen and get a clear yes before doing it. Reads are free; writes are gated.
|
||||
- **Never fabricate.** If the session didn't produce a decision worth persisting, say so and skip that write. An empty-but-honest closeout beats an invented one.
|
||||
|
||||
## Phase 1 — Harvest
|
||||
@@ -34,76 +34,46 @@ For every repo touched this session, get its true current state before proposing
|
||||
|
||||
Do not write anything in this phase. This is the read pass.
|
||||
|
||||
## Phase 3 — Confirm and act on issue changes
|
||||
## Phase 3 — Plan the issue changes
|
||||
|
||||
Present a single consolidated plan of issue actions: which to close (with closing comment), which to file (discovered-but-deferred work — token-budget gaps, recorded limitations, v2 follow-ups), which to comment on. Include the exact title/body for any new issue and the closing rationale for any close.
|
||||
Decide the issue actions: which to close (with closing comment), which to file (discovered-but-deferred work — token-budget gaps, recorded limitations, v2 follow-ups), which to comment on. Include the exact title/body for any new issue and the closing rationale for any close.
|
||||
|
||||
**GATE — STOP and get explicit confirmation before any issue write.** Issue closes and new issues are side-effects. Once confirmed, execute them (`gitea:issue_close`, `gitea:issue_create`, `gitea:issue_comment`, all owner `mathias`), correcting any rotted references you found in Phase 2 as you go.
|
||||
These actions are **carried into the Phase 4 capture call** as `tickets[]` rather than executed here with direct `gitea:issue_*` calls — routing them through capture puts each one into the I5 audit record. (Closing an issue that needs a separate explanatory comment first is the one case to do directly; otherwise prefer the capture path.)
|
||||
|
||||
## Phase 4 — Commit the canonical session summary
|
||||
## Phase 4 — Capture (one uniform call)
|
||||
|
||||
Write one summary file to `mathias/ai-sessions`, committed directly to `main` via `gitea:file_write_branch` (no PR — this repo is solo and unprotected; if branch protection is ever added, fall back to a branch + PR).
|
||||
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets, summary → ai-sessions — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
|
||||
|
||||
**Path:** `summaries/claudeai/<YYYY-MM>/<YYYY-MM-DD>-<topic-slug>-<chatid8>.md`
|
||||
where `<chatid8>` is the first 8 chars of the chat's UUID if known, else a short stable slug. `claudeai` has no host segment — Claude.ai is Anthropic-side, not a homelab host.
|
||||
**Assemble one payload:**
|
||||
|
||||
**Frontmatter — the REDUCED live-capture schema.** A live close-session capture cannot populate the batch-export telemetry (token counts, message counts, duration_ms, permission_mode) — those only exist in the account export pipeline. Write only what's truthfully known, and mark fidelity so a reader (or the batch pipeline) can tell a live capture from an export:
|
||||
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
|
||||
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
|
||||
- **`summary`** — `{title, body, repos_touched}`. Capture writes it to `ai-sessions` and stamps `fidelity` in frontmatter. Body stays reconstructable: one-paragraph summary, decisions, key artifacts, open threads.
|
||||
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "<concise session title>"
|
||||
client: "claudeai"
|
||||
interface: "claudeai-chat"
|
||||
date: "<YYYY-MM-DD>"
|
||||
repos_touched: [<repo slugs>]
|
||||
topic_tags: [<tags>]
|
||||
outcome: "<shipped|in-progress|abandoned>"
|
||||
fidelity: "live-capture" # NOT an export; reconstructed live from chat
|
||||
captured_by: "close-session-skill"
|
||||
---
|
||||
```
|
||||
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
|
||||
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`, and every entry in `summary.repos_touched`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
|
||||
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
|
||||
- Declare `context.classification: "internal"` for normal homelab work.
|
||||
- `summary.repos_touched`, insight `wing`s, and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged. The summary always lands in `ai-sessions` (internal), so the summary path itself never escalates.
|
||||
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
|
||||
|
||||
Do not invent the export-only fields. `fidelity: live-capture` is the honest signal; if the batch export later produces a richer summary for the same session, the export is source of truth and supersedes this.
|
||||
**GATE — dry-run first, then execute.**
|
||||
1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing.
|
||||
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets/summary that would land) and get explicit confirmation.
|
||||
3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed.
|
||||
|
||||
**Body** (keep it reconstructable, not exhaustive):
|
||||
```markdown
|
||||
## One-paragraph summary
|
||||
## Decisions
|
||||
## Key artifacts
|
||||
## Open threads
|
||||
```
|
||||
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`gitea:file_write_branch` summary + `brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
|
||||
|
||||
**GATE — STOP, show the full file (path + frontmatter + body), get explicit confirmation before committing.**
|
||||
|
||||
## Phase 5 — Brain orientation note (the durable "where we are" record)
|
||||
|
||||
Write one brain note so a fresh session can orient without the chat. This uses the `brain_update`/`brain_get` verbs (live since 2026-06).
|
||||
|
||||
**Target:** `wing: <domain>` (the project/topic domain, e.g. `hyperguild`, `jepa-fx`), `hall: decisions`. The note is a knowledge-type record (a decision/orientation), grouped by knowledge-type, not by interface surface.
|
||||
|
||||
**Batch read-after-write discipline (important — do these in order, do not interleave):**
|
||||
|
||||
1. **Read first, before any write.** Check whether an orientation note already exists for this wing/topic. Do your "does this already exist / what should I supersede" reads NOW, up front. BM25/keyword search and `brain_get` are immediate; semantic/vector search may lag up to ~5 min after a write, so never rely on a semantic query to find something you wrote earlier in this same run.
|
||||
2. **Write or supersede:**
|
||||
- **New note** → `brain_write` (wing, hall: decisions). Returns `{id, path, content_hash}`.
|
||||
- **Superseding a prior orientation note** → `brain_update` (slug or path, wing, hall, content, reason). Whole-note replace; stamps `supersedes`/`updated_at`; returns `{id, path, content_hash, superseded}`. Use this instead of a second `brain_write` to the same slug — blind re-write creates duplicates/contradictions, which is the exact failure brain_update exists to prevent.
|
||||
3. **Confirm it landed** via `brain_get(id)` and check the returned `content_hash` matches what the write returned. This is the read-after-write confirmation — do it with `brain_get`, never a semantic query.
|
||||
|
||||
**RULE: no semantic/vector brain query after the first `brain_update` in this run.** The batch shape makes this natural — read up front, write, confirm by id. If you ever find the skill wanting to semantic-search a just-superseded note, stop and flag it (that's the signal the staleness window matters and needs the synchronous-reembed follow-up).
|
||||
|
||||
**GATE — STOP, show the note (target wing/hall, new-vs-supersede, full content), get explicit confirmation before the brain write.**
|
||||
|
||||
After the note lands, if it relates to a note in another wing, create the cross-link inline with `brain_tunnel(source, target)` (idempotent; both paths brain-relative, must be in different wings). Optionally append a `session_log` entry (`session_id`, `skill: close-session`, `phase`, `final_status`) for telemetry. Both are now callable directly from Claude.ai — no Claude Code/Crush handoff needed.
|
||||
|
||||
## Phase 6 — Verdict
|
||||
## Phase 5 — Verdict
|
||||
|
||||
Deliver a final "safe to archive" verdict in the chat. Either:
|
||||
|
||||
- **SAFE TO ARCHIVE** — list what landed (issues closed/filed with numbers, summary path, brain note id, any tunnels) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
||||
- **NOT YET** — name the specific gate that wasn't passed or the write that failed, and what to do about it.
|
||||
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, summary path, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
||||
- **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it.
|
||||
|
||||
Never claim safe-to-archive if any gated write was declined or errored. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
||||
Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
||||
|
||||
## Why the gates and the batch discipline matter
|
||||
## Why the gate and the single-call shape matter
|
||||
|
||||
The whole point is durability across a context reset. Every gate is a place where a wrong write would silently corrupt the record (close the wrong issue, overwrite a good brain note, commit a half-truth). The batch read-discipline in Phase 5 exists because the brain's vector index refreshes out-of-band: write-then-semantically-reread in the same run can read stale, so the skill front-loads reads and confirms writes by id. Get those right and the skill does what it promises — nothing important is lost when the chat goes away.
|
||||
The whole point is durability across a context reset. The capture call is the one place a wrong payload would silently corrupt the record (close the wrong issue, escalate to a refusal, commit a half-truth), which is why it is dry-run-then-confirm. Routing everything through one `capture` keeps the supersession discipline, the read-after-write confirmation, and the I5 audit trail server-side — the skill never has to carry those rules itself, and every closeout is uniformly audited. Get the payload and the classification right and the skill does what it promises — nothing important is lost when the chat goes away.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Capture capability — implementation report (as-built)
|
||||
|
||||
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50–#55) closed.
|
||||
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
|
||||
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.
|
||||
|
||||
This document records what was actually built, where it lives, how it maps to the spec, and what was deferred — for onboarding and future audit. It does not restate the design rationale (see the spec and the linked brain entries).
|
||||
|
||||
---
|
||||
|
||||
## 1. Outcome
|
||||
|
||||
One uniform capture capability — insights → brain, action items → Gitea tickets, optional summary → ai-sessions — reachable identically from every harness:
|
||||
|
||||
- **In-process / direct-REST harnesses** (Claude Code CLI, Agentsquad, claude.ai Code, headless): `POST /capture` on the brain server.
|
||||
- **MCP-native harnesses** (claude.ai Chat/Cowork/Design, Crush, Pi, LLM Council): the `capture` MCP tool, reached over the existing `/mcp` OAuth connector.
|
||||
|
||||
Both doors call the **same** `CaptureService`; only the transport and credential assembly differ. The persistence behaviour (validation, classification, I1 gate, orchestration, I5 audit, partial receipt) is written once.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture (as-built)
|
||||
|
||||
```
|
||||
POST /capture (REST) capture MCP tool
|
||||
capturehttp.Handler mcp.Server.brainCapture
|
||||
\ /
|
||||
\ (auth → principal → /
|
||||
\ origin; decode) /
|
||||
v v
|
||||
capture.CaptureService (use-case, pure)
|
||||
┌───────────────┬───────────────┬──────────────┬───────────────┐
|
||||
BrainStore IssueTracker SummaryWriter ClassificationPolicy AuditSink
|
||||
brainstore. gitea.Client (nil today) classification.Config audit.Degrading
|
||||
Store (REST) Sink / SlogSink
|
||||
│ │
|
||||
api.WriteNote/UpdateNote/ReadNote (#45) LokiCentral + FileBuffer
|
||||
+ wing index + auto-tunnel + graph re-index + NtfyNotifier + Reconcile
|
||||
```
|
||||
|
||||
- **`internal/capture/`** — the use-case + ports + entities. Pure; no I/O. Owns validation (fail-closed), effective-classification resolution (stricter wins), the **I1 sovereignty gate**, best-effort orchestration, the **two-phase I5 audit** (Reserve before writes / Record after), and the partial-aware receipt.
|
||||
- **`internal/brainstore/`** — concrete `BrainStore` wrapping the #45 `api` primitives + wiki upkeep (wing `_index`, auto-tunnel, graph re-index). The MCP `brain_write`/`brain_update`/`brain_get` handlers were re-pointed at it: one implementation, not two.
|
||||
- **`internal/classification/`** — `public < internal < confidential` taxonomy + per-wing/repo tags from an optional `classification.yaml`; fail-safe to confidential.
|
||||
- **`internal/gitea/`** — `IssueTracker` over the Gitea REST API; owner forced to `mathias`; token only in the Authorization header.
|
||||
- **`internal/capturehttp/`** — the REST adapter + the shared `Authenticate` / `DecodeRequest` / `OriginResolver` (also used by the MCP tool).
|
||||
- **`internal/audit/`** — `SlogSink` (default) and the `DegradingSink` (loki + durable `FileBuffer` + `NtfyNotifier` + `Reconcile`).
|
||||
- **`internal/mcp/`** — the `capture` relay tool + principal threading (re-derives the caller's principal from the Bearer header the chassis middleware discards).
|
||||
|
||||
---
|
||||
|
||||
## 3. Sub-issue → PR map
|
||||
|
||||
| Sub | Issue | PR(s) | Delivered |
|
||||
|-----|-------|-------|-----------|
|
||||
| 49a | #50 | #56 | classification taxonomy + per-wing/repo tags (fail-safe to confidential) |
|
||||
| 49b | #51 | #57 | `CaptureService` use-case + ports + entities; `BrainStore` extraction (MCP re-pointed) |
|
||||
| 49c | #52 | #58 | Gitea `IssueTracker` (owner forced mathias; token never logged) |
|
||||
| 49d | #53 | #59 | `POST /capture` REST + OAuth2 + I1 sovereignty gate (server-derived origin) |
|
||||
| 49e | #54 | #60 | I5 audit path + classification-aware degradation (loki + buffer + reconcile) |
|
||||
| 49f | #55 | #61, infra #151 (ledger), #152 (deploy) | MCP `capture` relay tool + I2 ledger + I3 deploy |
|
||||
|
||||
Predecessor: #45 (`brain_update`/`brain_get` verbs, PR #46) — the read-after-write contract capture reuses.
|
||||
|
||||
---
|
||||
|
||||
## 4. Invariant compliance
|
||||
|
||||
| Inv | How satisfied |
|
||||
|-----|---------------|
|
||||
| **I1** sovereign containment | Effective classification = stricter(caller-declared, target-derived #50). Origin is **server-derived from the authenticated principal**, never `context.harness`. Confidential + us-nexus origin → refused before any write; refusal audited. Asserted-vs-derived mismatch → security event. |
|
||||
| **I2** deliberate acceptance | The relay's cross-harness reach is recorded in `infra/docs/security-baseline.md` with six containment properties + Revisit-if, **merged before relay code shipped** (infra #151). |
|
||||
| **I3** GitOps reconcilability | Env + `gitea-api-token` ExternalSecret under `infra/k3s/apps/supervisor/`, Flux-reconciled; image bumped by CD. No untracked runtime. |
|
||||
| **I5** auditability | Every capture emits a request-level audit record. Classification-aware degradation: confidential + sink-down → hard-refuse; internal/public + sink-down → durable local buffer + ntfy + reconcile-on-recovery; floor → refuse if nothing can record. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Operational reference (env)
|
||||
|
||||
Set on the `ingestion` deployment (`infra/k3s/apps/supervisor/ingestion-deployment.yaml`):
|
||||
|
||||
| Env | Purpose | Notes |
|
||||
|-----|---------|-------|
|
||||
| `BRAIN_GITEA_URL` / `BRAIN_GITEA_TOKEN` | enables the `IssueTracker` → gates `/capture` + the MCP tool | token from 1P `DMABE_GITEA_API_TOKEN` via ESO; unset ⇒ capture disabled |
|
||||
| `BRAIN_LOKI_URL` | activates the `DegradingSink` | unset ⇒ `SlogSink` (audit to stdout → alloy → loki; no refuse/buffer semantics) |
|
||||
| `BRAIN_NTFY_URL` / `BRAIN_NTFY_TOKEN` | degraded-state alerts | optional |
|
||||
| `BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS` | JWT subjects treated as sovereign-soil | comma-separated; static-token caller is always sovereign; unknown JWT ⇒ us-nexus (fail safe) |
|
||||
| `BRAIN_AUDIT_RECONCILE_INTERVAL` | buffer→loki replay tick | default 60s |
|
||||
|
||||
The audit buffer lives at `<brain>/.audit-buffer/capture.jsonl` on the brain hostPath (nodeSelector-pinned to koala) — durable across restart without a separate PV.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests
|
||||
|
||||
67 test functions across the six packages. Coverage maps to the spec's Gherkin: happy path, supersede-not-duplicate, fail-closed validation, partial-failure receipt, dry-run, stricter-classification-wins, I1 confidential-via-us-nexus-refused / via-sovereign-allowed / asserted-label-ignored / caller-cannot-forge-origin, I5 confidential-refuse / internal-buffer / floor-refuse / reconcile / buffer-survives-restart, and the MCP relay tool (forwards, preserves principal, unauth rejected). `task check` green.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deferred (not in this epic)
|
||||
|
||||
Tracked here so they aren't lost; file as issues when picked up:
|
||||
|
||||
- **SKILL veneer** — the `close-session` SKILL becomes the claude.ai trigger/harvest layer that calls capture.
|
||||
- **Per-harness token provisioning** for Crush / Pi / LLM Council (claude.ai is done via the existing `/mcp` connector).
|
||||
- **Harvest adapters** — transcript-parse vs chat-memory-reconstruct vs agent-runlog, each assembling capture args at its own fidelity.
|
||||
- **`SummaryWriter` impl** — ai-sessions summary persistence (the port + path logic exist; the concrete writer is nil today, so a request with a summary fails that one item).
|
||||
|
||||
---
|
||||
|
||||
## 8. Brain learnings
|
||||
|
||||
- `wiki/hyperguild/decisions/capture-classification-taxonomy`
|
||||
- `wiki/hyperguild/decisions/gate-on-server-derived-signals-fail-safe`
|
||||
- `wiki/hyperguild/decisions/two-phase-reserve-record-audit-gate`
|
||||
- `wiki/hyperguild/failures/mcp-bearer-middleware-discards-principal`
|
||||
- `wiki/hyperguild/facts/brain-mcp-embeddings-out-of-band-sync` (from #45, the predecessor)
|
||||
Reference in New Issue
Block a user