Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e5f62c8e | ||
|
|
b9d03316fd | ||
|
|
da9bdc4cbb | ||
|
|
dcb9ff4a56 | ||
|
|
0454527b83 | ||
|
|
ef11864121 | ||
|
|
14b04a25cb | ||
|
|
66a9b8e725 |
+1
-1
@@ -53,7 +53,7 @@ func main() {
|
|||||||
|
|
||||||
router := &routing.Router{
|
router := &routing.Router{
|
||||||
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
|
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
|
||||||
Logger: routing.NewLogger(cfg.BrainURL),
|
Logger: routing.NewLogger(cfg.BrainURL, cfg.BrainMCPToken),
|
||||||
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
|
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
|
||||||
FastModel: cfg.FastModel,
|
FastModel: cfg.FastModel,
|
||||||
ThinkingModel: cfg.ThinkingModel,
|
ThinkingModel: cfg.ThinkingModel,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Runbook: exercising review/debug traffic to fill the pass-rate dataset
|
||||||
|
|
||||||
|
**Why this exists:** the routing pod's local-vs-cloud decision is gated on a
|
||||||
|
pass-rate history that only accrues from real `review`/`debug` invocations
|
||||||
|
**through the pod**. Until the dataset has data, the fast (local) path never
|
||||||
|
activates and the core hypothesis (hyperguild #35) can't be validated. This
|
||||||
|
runbook is how you spin that flywheel.
|
||||||
|
|
||||||
|
## The one trap
|
||||||
|
|
||||||
|
Pass-rate accrues **only** when a skill tool is called via the routing pod's MCP
|
||||||
|
endpoint. These look like they should count but **do not**:
|
||||||
|
|
||||||
|
- **Crush** — talks to LiteLLM directly, bypasses the pod. No log.
|
||||||
|
- **claude.ai web / Claude Desktop without the connector** — no log.
|
||||||
|
- **Running the local `code-review` / `debug` skills** (`~/dev/.skills`) inline in
|
||||||
|
a Claude Code session — those are local skills, not the pod's MCP tools. No log.
|
||||||
|
|
||||||
|
Only a `tools/call` to the routing pod records a pass/fail.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Purpose | URL | Auth |
|
||||||
|
|---------|-----|------|
|
||||||
|
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
||||||
|
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
||||||
|
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
|
||||||
|
|
||||||
|
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
||||||
|
plus `session_log`, `retrospective`, `trainer`.
|
||||||
|
|
||||||
|
## Step 1 — connect the routing pod as an MCP server
|
||||||
|
|
||||||
|
**Local** (needs the bearer token; keep it out of argv via 1Password):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
op run --env-file ~/.op-env -- \
|
||||||
|
claude mcp add routing --transport http http://koala:30310/mcp \
|
||||||
|
--header "Authorization: Bearer $ROUTING_MCP_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remote** (claude.ai / Claude Desktop): add a custom connector pointing at
|
||||||
|
`https://routing-mcp.d-ma.be/mcp`; it completes OAuth against `auth.d-ma.be`,
|
||||||
|
no static token.
|
||||||
|
|
||||||
|
Verify: a `tools/list` should return `review`, `debug`, `session_log`,
|
||||||
|
`retrospective`, `trainer`.
|
||||||
|
|
||||||
|
## Step 2 — route real work through it
|
||||||
|
|
||||||
|
In normal sessions, invoke the pod's tools instead of reviewing/debugging inline:
|
||||||
|
|
||||||
|
- *"Use the **routing** `review` tool on this diff."*
|
||||||
|
- *"**debug** this failure through the routing pod."*
|
||||||
|
|
||||||
|
Each call logs an outcome to ingestion → `/pass-rate` ticks up.
|
||||||
|
|
||||||
|
## Step 3 — how routing actually picks the model
|
||||||
|
|
||||||
|
Per `internal/routing/policy.go`:
|
||||||
|
|
||||||
|
1. pass-rate `nil` (cold) → **local** fast tier. The router defaults to local
|
||||||
|
from invocation #1, not to cloud — so the fast tier is exercised immediately.
|
||||||
|
2. pass-rate `>= 0.90` (floor) → **local**; `< 0.70` (ceil) → **cloud/thinking**;
|
||||||
|
in the `[0.70, 0.90)` band a request-hash bit samples 50/50.
|
||||||
|
3. On a local execution error the router falls open to the thinking model for
|
||||||
|
that one call (logged `thinking_fallback`).
|
||||||
|
|
||||||
|
So you are not "paying in on cloud" — cold calls already run on the (validated)
|
||||||
|
local fast tier **`koala/qwen36-35b-a3b`** (Qwen3.6-35B-A3B MTP, promoted
|
||||||
|
2026-06-29, infra `c66a195`, `HYPERGUILD_FAST_MODEL`). Accumulating passes just
|
||||||
|
keeps it there once real pass-rate is computed.
|
||||||
|
|
||||||
|
> **Instrumentation note (#73, fixed 2026-06-30):** until v0.11.1 the pod logged
|
||||||
|
> successes as `"skip"` (not `"pass"`), under `skill:"_routing"`, via an
|
||||||
|
> unauthenticated POST that silently 401'd — so `/pass-rate` stayed at zero no
|
||||||
|
> matter how much you used it. That's fixed and verified (a real review call now
|
||||||
|
> moves `/pass-rate?skill=review` 0→1). If you see traffic not registering,
|
||||||
|
> re-check #73's three failure modes first.
|
||||||
|
|
||||||
|
## Target & verification
|
||||||
|
|
||||||
|
- **50 logged invocations** across `review` + `debug` within the 14-day window.
|
||||||
|
The clock restarts **2026-06-30** (the day instrumentation was verified working;
|
||||||
|
the original 2026-06-26→07-10 window measured broken plumbing) → **kill-date
|
||||||
|
2026-07-14**, ~4 calls/day (1 already logged from the #73 smoke test).
|
||||||
|
- Check progress anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://koala:30330/pass-rate?skill=review"
|
||||||
|
curl -s "http://koala:30330/pass-rate?skill=debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
- If ~4–5/day isn't realistic alongside Crush, that is **not** a failure — per
|
||||||
|
#35 deliverable #1 it's the signal hyperguild isn't on the work critical path,
|
||||||
|
and the pre-decided **Berget fallback** (`gpt-oss-120b` / `qwen3-32b`) carries
|
||||||
|
the fast tier instead.
|
||||||
|
|
||||||
|
## Refs
|
||||||
|
|
||||||
|
- hyperguild #35 — the validation issue (data gate = deliverable #1)
|
||||||
|
- `docs/multi-model-routing.md` — routing policy
|
||||||
|
- brain: `wiki/homelab/hypotheses/qwen36-35b-a3b-fast-model-experiment-2026-05-28.md`
|
||||||
|
- infra `c66a195` — qwen36 promotion; `models.yml` / `llama-swap-configmap.yaml`
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
@@ -369,6 +369,8 @@ func main() {
|
|||||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
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("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||||
|
|||||||
+8
-5
@@ -2,19 +2,22 @@ module github.com/mathiasbq/hyperguild/ingestion
|
|||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.11.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
git.d-ma.be/mathias/mcp-chassis v0.2.0
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
@@ -27,5 +30,5 @@ require (
|
|||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-3
@@ -1,5 +1,6 @@
|
|||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
|
||||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -15,6 +16,10 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
@@ -29,6 +34,8 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB
|
|||||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -46,9 +53,9 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
|||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -483,6 +483,40 @@ func (h *Handler) BackfillRefs(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]int{"updated": n})
|
writeJSON(w, map[string]int{"updated": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pending handles GET /pending — list raw/ notes awaiting promotion.
|
||||||
|
func (h *Handler) Pending(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
pending, err := ListPending(h.brainDir)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("pending failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "pending error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"pending": pending})
|
||||||
|
}
|
||||||
|
|
||||||
|
type promoteRequest struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||||
|
// hall / collision / missing source is a 400 (caller error), not a 500.
|
||||||
|
func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req promoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"path": rel})
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PendingNote describes a raw/ note awaiting human promotion to the wiki.
|
||||||
|
type PendingNote struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
||||||
|
// deriving the default promoted slug.
|
||||||
|
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
||||||
|
|
||||||
|
// ListPending returns the notes in brain/raw/ awaiting review, oldest-first
|
||||||
|
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
||||||
|
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
||||||
|
func ListPending(brainDir string) ([]PendingNote, error) {
|
||||||
|
dir := filepath.Join(brainDir, "raw")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []PendingNote{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]PendingNote, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, statErr := e.Info()
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if readErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
created := fm.get("created_at")
|
||||||
|
if created == "" {
|
||||||
|
created = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(),
|
||||||
|
CreatedAt: created,
|
||||||
|
SizeBytes: info.Size(),
|
||||||
|
Excerpt: excerpt(body, 200),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteOptions identifies a raw note to promote and its wiki destination.
|
||||||
|
type PromoteOptions struct {
|
||||||
|
Filename string // basename in brain/raw/
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
||||||
|
// rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and
|
||||||
|
// any custom fields), writes to brain/wiki/<wing>/<hall>/<slug>.md, deletes
|
||||||
|
// the source, then rebuilds the wing index and runs auto-tunnel detection.
|
||||||
|
//
|
||||||
|
// It is atomic from the caller's view: validation (hall, wing, slug,
|
||||||
|
// collision) happens before any filesystem change, and the source is deleted
|
||||||
|
// only after the destination write succeeds (write-then-delete, never move).
|
||||||
|
// Returns the promoted note's path relative to brainDir.
|
||||||
|
func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||||
|
// Validate filename (basename only — no traversal) before touching fs.
|
||||||
|
base := filepath.Base(opts.Filename)
|
||||||
|
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
||||||
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := opts.Slug
|
||||||
|
if slug == "" {
|
||||||
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||||
|
}
|
||||||
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||||
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
|
raw, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("read source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision: never silently overwrite an existing promoted note.
|
||||||
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
fm.set("wing", brain.Sanitise(opts.Wing))
|
||||||
|
fm.set("hall", opts.Hall)
|
||||||
|
if fm.get("created_at") == "" {
|
||||||
|
fm.set("created_at", now)
|
||||||
|
}
|
||||||
|
fm.set("promoted_at", now)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create wing dir: %w", err)
|
||||||
|
}
|
||||||
|
// Write-then-delete: the source survives any write failure.
|
||||||
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write promoted note: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(src); err != nil {
|
||||||
|
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
// Best-effort wiki upkeep — the note is already promoted.
|
||||||
|
_ = brain.BuildWingIndex(brainDir, opts.Wing)
|
||||||
|
_ = brain.AutoTunnel(brainDir, relSlash, body)
|
||||||
|
|
||||||
|
return relSlash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
||||||
|
func excerpt(s string, n int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) > n {
|
||||||
|
r = r[:n]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r))
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeRaw(t *testing.T, brainDir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingEmptyWhenAbsent(t *testing.T) {
|
||||||
|
got, err := ListPending(t.TempDir())
|
||||||
|
require.NoError(t, err, "absent raw/ is not an error")
|
||||||
|
assert.Empty(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingReturnsOldestFirstWithExcerpt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-02-newer.md", "---\ncreated_at: 2026-06-02T00:00:00Z\n---\nNewer body here.\n")
|
||||||
|
writeRaw(t, dir, "2026-06-01-older.md", "---\ncreated_at: 2026-06-01T00:00:00Z\n---\nOlder body content.\n")
|
||||||
|
// non-md ignored
|
||||||
|
writeRaw(t, dir, "notes.txt", "ignore me")
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
assert.Equal(t, "2026-06-01-older.md", got[0].Filename, "oldest first")
|
||||||
|
assert.Equal(t, "2026-06-02-newer.md", got[1].Filename)
|
||||||
|
assert.Contains(t, got[0].Excerpt, "Older body content")
|
||||||
|
assert.NotContains(t, got[0].Excerpt, "---", "excerpt is body, not frontmatter")
|
||||||
|
assert.Positive(t, got[0].SizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteHappyPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-lejpa-decision.md",
|
||||||
|
"---\ncreated_at: 2026-06-01T09:00:00Z\ncustom_field: keep-me\n---\n# LeJEPA\n\nbody.\n")
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{
|
||||||
|
Filename: "2026-06-01-lejpa-decision.md", Wing: "jepa-fx", Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/jepa-fx/decisions/lejpa-decision.md", rel, "slug defaults to filename minus date prefix")
|
||||||
|
|
||||||
|
// Source deleted.
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-lejpa-decision.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "source removed after promote")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "wing: jepa-fx")
|
||||||
|
assert.Contains(t, s, "hall: decisions")
|
||||||
|
assert.Contains(t, s, "created_at: 2026-06-01T09:00:00Z", "original created_at preserved")
|
||||||
|
assert.Contains(t, s, "promoted_at:")
|
||||||
|
assert.Contains(t, s, "custom_field: keep-me", "custom frontmatter preserved")
|
||||||
|
assert.Contains(t, s, "# LeJEPA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteExplicitSlug(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts", Slug: "custom-slug"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/custom-slug.md", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteInvalidHallErrorsBeforeTouchingFS(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "garbage"})
|
||||||
|
require.Error(t, err)
|
||||||
|
// Source untouched.
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||||
|
assert.NoError(t, statErr, "invalid hall must not delete or move the source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteMissingSourceErrors(t *testing.T) {
|
||||||
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "ghost.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteSlugCollisionNoOverwrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Pre-existing target.
|
||||||
|
dest := filepath.Join(dir, "wiki", "a", "facts", "x.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "NEW\n")
|
||||||
|
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err, "collision must error, not overwrite")
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||||
|
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteRejectsTraversalFilename(t *testing.T) {
|
||||||
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "../escape.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteRebuildsWingIndex(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "---\ntitle: X Note\n---\nbody\n")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
idx, err := os.ReadFile(filepath.Join(dir, "wiki", "a", "_index.md"))
|
||||||
|
require.NoError(t, err, "wing _index regenerated")
|
||||||
|
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"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"),
|
"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/<wing>/<hall>/: 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",
|
"name": "brain_tunnel",
|
||||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
"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
|
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||||
// logs failures but never propagates them — the underlying write/ingest
|
// logs failures but never propagates them — the underlying write/ingest
|
||||||
// has already succeeded and the graph is an augmentation, not a
|
// has already succeeded and the graph is an augmentation, not a
|
||||||
|
|||||||
@@ -332,3 +332,61 @@ func TestSessionLogRequiresSessionID(t *testing.T) {
|
|||||||
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
||||||
require.NotNil(t, resp["error"])
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||||
// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw,
|
// brain_pending, brain_promote, brain_index, brain_tunnel, brain_ingest,
|
||||||
// brain_answer, brain_classify, brain_graph, brain_context, session_log,
|
// brain_ingest_raw, brain_answer, brain_classify, brain_graph,
|
||||||
// and capture (the #55 relay tool, registered only when WithCapture is set).
|
// brain_context, session_log, and capture (the #55 relay tool, registered
|
||||||
|
// only when WithCapture is set).
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -263,6 +264,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainUpdate(ctx, args)
|
return s.brainUpdate(ctx, args)
|
||||||
case "brain_get":
|
case "brain_get":
|
||||||
return s.brainGet(ctx, args)
|
return s.brainGet(ctx, args)
|
||||||
|
case "brain_pending":
|
||||||
|
return s.brainPending(ctx, args)
|
||||||
|
case "brain_promote":
|
||||||
|
return s.brainPromote(ctx, args)
|
||||||
case "capture":
|
case "capture":
|
||||||
return s.brainCapture(ctx, args)
|
return s.brainCapture(ctx, args)
|
||||||
case "brain_index":
|
case "brain_index":
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||||
|
"brain_pending", "brain_promote",
|
||||||
"brain_index", "brain_tunnel",
|
"brain_index", "brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type RoutingConfig struct {
|
|||||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
||||||
|
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
|
||||||
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
||||||
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
||||||
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
||||||
@@ -44,6 +45,7 @@ func LoadRouting() (RoutingConfig, error) {
|
|||||||
LiteLLMBaseURL: envOr("LITELLM_BASE_URL", "https://llm-api.d-ma.be"),
|
LiteLLMBaseURL: envOr("LITELLM_BASE_URL", "https://llm-api.d-ma.be"),
|
||||||
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
||||||
BrainURL: envOr("BRAIN_URL", "http://ingestion.supervisor:3300"),
|
BrainURL: envOr("BRAIN_URL", "http://ingestion.supervisor:3300"),
|
||||||
|
BrainMCPToken: os.Getenv("BRAIN_MCP_TOKEN"),
|
||||||
FastModel: envOr("HYPERGUILD_FAST_MODEL", "koala/qwen35-9b-fast"),
|
FastModel: envOr("HYPERGUILD_FAST_MODEL", "koala/qwen35-9b-fast"),
|
||||||
ThinkingModel: envOr("HYPERGUILD_THINKING_MODEL", "iguana/gemma4-26b"),
|
ThinkingModel: envOr("HYPERGUILD_THINKING_MODEL", "iguana/gemma4-26b"),
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-5
@@ -17,19 +17,24 @@ type LogEntry struct {
|
|||||||
Message string // free-form, e.g. "model=qwen35, pass_rate=0.94"
|
Message string // free-form, e.g. "model=qwen35, pass_rate=0.94"
|
||||||
ProjectRoot string
|
ProjectRoot string
|
||||||
DurationMs int64
|
DurationMs int64
|
||||||
Failed bool // true → final_status: "fail"; false → "skip"
|
Failed bool // true → final_status: "fail"; false → "pass"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger posts session_log entries to a brain MCP at BrainURL + /mcp.
|
// Logger posts session_log entries to a brain MCP at BrainURL + /mcp.
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
BrainURL string
|
BrainURL string
|
||||||
|
Token string // bearer for the (auth-gated) ingestion /mcp; empty = no header
|
||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogger creates a Logger with a 2-second HTTP timeout.
|
// NewLogger creates a Logger with a 2-second HTTP timeout. token authenticates
|
||||||
func NewLogger(brainURL string) *Logger {
|
// to the bearer-gated ingestion /mcp; an empty token sends no Authorization
|
||||||
|
// header (and silently 401s against a gated server — see brain
|
||||||
|
// mcpclient-empty-token-silent-401-envfrom-missing-key).
|
||||||
|
func NewLogger(brainURL, token string) *Logger {
|
||||||
return &Logger{
|
return &Logger{
|
||||||
BrainURL: brainURL,
|
BrainURL: brainURL,
|
||||||
|
Token: token,
|
||||||
HTTP: &http.Client{Timeout: 2 * time.Second},
|
HTTP: &http.Client{Timeout: 2 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +42,10 @@ func NewLogger(brainURL string) *Logger {
|
|||||||
// LogDecision posts a session_log MCP call. Errors are returned but the caller
|
// LogDecision posts a session_log MCP call. Errors are returned but the caller
|
||||||
// MUST NOT block real work on them — logging is best-effort.
|
// MUST NOT block real work on them — logging is best-effort.
|
||||||
func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error {
|
func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error {
|
||||||
status := "skip"
|
// A completed routed call is a pass (liveness); only an execution error is a
|
||||||
|
// fail. There is no "skip" for routing — the prior default-to-"skip" meant a
|
||||||
|
// successful call never counted toward pass_rate, so the gate was unreachable.
|
||||||
|
status := "pass"
|
||||||
if e.Failed {
|
if e.Failed {
|
||||||
status = "fail"
|
status = "fail"
|
||||||
}
|
}
|
||||||
@@ -49,7 +57,9 @@ func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error {
|
|||||||
"name": "session_log",
|
"name": "session_log",
|
||||||
"arguments": map[string]any{
|
"arguments": map[string]any{
|
||||||
"session_id": e.SessionID,
|
"session_id": e.SessionID,
|
||||||
"skill": "_routing",
|
// The real skill, so /pass-rate?skill=review|debug sees these
|
||||||
|
// records; routing decisions stay groupable via session_id "_routing".
|
||||||
|
"skill": e.Skill,
|
||||||
"phase": "decide",
|
"phase": "decide",
|
||||||
"final_status": status,
|
"final_status": status,
|
||||||
"message": fmt.Sprintf("%s: %s — %s", e.Skill, e.Decision, e.Message),
|
"message": fmt.Sprintf("%s: %s — %s", e.Skill, e.Decision, e.Message),
|
||||||
@@ -67,6 +77,9 @@ func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error {
|
|||||||
return fmt.Errorf("log: build request: %w", err)
|
return fmt.Errorf("log: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if l.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+l.Token)
|
||||||
|
}
|
||||||
resp, err := l.HTTP.Do(req)
|
resp, err := l.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("log: request: %w", err)
|
return fmt.Errorf("log: request: %w", err)
|
||||||
|
|||||||
@@ -15,36 +15,44 @@ import (
|
|||||||
|
|
||||||
func TestLoggerLogDecision(t *testing.T) {
|
func TestLoggerLogDecision(t *testing.T) {
|
||||||
var captured map[string]any
|
var captured map[string]any
|
||||||
|
var authHeader string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
assert.Equal(t, "/mcp", r.URL.Path)
|
assert.Equal(t, "/mcp", r.URL.Path)
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
require.NoError(t, json.Unmarshal(body, &captured))
|
require.NoError(t, json.Unmarshal(body, &captured))
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}}})
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}}})
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
l := routing.NewLogger(srv.URL)
|
l := routing.NewLogger(srv.URL, "test-token")
|
||||||
err := l.LogDecision(context.Background(), routing.LogEntry{
|
err := l.LogDecision(context.Background(), routing.LogEntry{
|
||||||
SessionID: "sess-1",
|
SessionID: "sess-1",
|
||||||
Skill: "review",
|
Skill: "review",
|
||||||
Decision: "local",
|
Decision: "local",
|
||||||
Message: "model=qwen35, pass_rate=0.94",
|
Message: "model=qwen36, pass_rate=0.94",
|
||||||
ProjectRoot: "/home/x/proj",
|
ProjectRoot: "/home/x/proj",
|
||||||
DurationMs: 1234,
|
DurationMs: 1234,
|
||||||
Failed: false,
|
Failed: false,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Bug C fix: the POST authenticates to the bearer-gated ingestion /mcp.
|
||||||
|
assert.Equal(t, "Bearer test-token", authHeader)
|
||||||
|
|
||||||
params := captured["params"].(map[string]any)
|
params := captured["params"].(map[string]any)
|
||||||
assert.Equal(t, "tools/call", captured["method"])
|
assert.Equal(t, "tools/call", captured["method"])
|
||||||
assert.Equal(t, "session_log", params["name"])
|
assert.Equal(t, "session_log", params["name"])
|
||||||
|
|
||||||
args := params["arguments"].(map[string]any)
|
args := params["arguments"].(map[string]any)
|
||||||
assert.Equal(t, "_routing", args["skill"])
|
// Bug B fix: the record carries the real skill so /pass-rate?skill=review sees it.
|
||||||
|
assert.Equal(t, "review", args["skill"])
|
||||||
assert.Equal(t, "decide", args["phase"])
|
assert.Equal(t, "decide", args["phase"])
|
||||||
assert.Equal(t, "skip", args["final_status"])
|
// Bug A fix: a successful routed call logs "pass", not "skip".
|
||||||
|
assert.Equal(t, "pass", args["final_status"])
|
||||||
assert.Contains(t, args["message"].(string), "review: local")
|
assert.Contains(t, args["message"].(string), "review: local")
|
||||||
|
// session grouping is preserved via session_id.
|
||||||
assert.Equal(t, "sess-1", args["session_id"])
|
assert.Equal(t, "sess-1", args["session_id"])
|
||||||
assert.Equal(t, "/home/x/proj", args["project_root"])
|
assert.Equal(t, "/home/x/proj", args["project_root"])
|
||||||
assert.Equal(t, float64(1234), args["duration_ms"])
|
assert.Equal(t, float64(1234), args["duration_ms"])
|
||||||
@@ -59,23 +67,40 @@ func TestLoggerLogFailure(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
l := routing.NewLogger(srv.URL)
|
l := routing.NewLogger(srv.URL, "test-token")
|
||||||
err := l.LogDecision(context.Background(), routing.LogEntry{
|
err := l.LogDecision(context.Background(), routing.LogEntry{
|
||||||
SessionID: "s", Skill: "debug", Decision: "local", Message: "litellm down", Failed: true,
|
SessionID: "s", Skill: "debug", Decision: "local", Message: "litellm down", Failed: true,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
args := captured["params"].(map[string]any)["arguments"].(map[string]any)
|
args := captured["params"].(map[string]any)["arguments"].(map[string]any)
|
||||||
|
assert.Equal(t, "debug", args["skill"])
|
||||||
assert.Equal(t, "fail", args["final_status"])
|
assert.Equal(t, "fail", args["final_status"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoggerOmitsAuthWhenTokenEmpty(t *testing.T) {
|
||||||
|
var authHeader string
|
||||||
|
hasAuth := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
|
_, hasAuth = r.Header["Authorization"]
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
l := routing.NewLogger(srv.URL, "")
|
||||||
|
require.NoError(t, l.LogDecision(context.Background(), routing.LogEntry{Skill: "review", SessionID: "_routing", Decision: "local"}))
|
||||||
|
assert.False(t, hasAuth, "no Authorization header should be set when token is empty")
|
||||||
|
assert.Equal(t, "", authHeader)
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoggerSurfacesUpstreamError(t *testing.T) {
|
func TestLoggerSurfacesUpstreamError(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
http.Error(w, "down", http.StatusBadGateway)
|
http.Error(w, "down", http.StatusBadGateway)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
l := routing.NewLogger(srv.URL)
|
l := routing.NewLogger(srv.URL, "test-token")
|
||||||
err := l.LogDecision(context.Background(), routing.LogEntry{Skill: "x", SessionID: "y", Decision: "local"})
|
err := l.LogDecision(context.Background(), routing.LogEntry{Skill: "x", SessionID: "y", Decision: "local"})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func newRouter(t *testing.T, llm *fakeLLM, passRate float64) (*routing.Router, *
|
|||||||
|
|
||||||
r := &routing.Router{
|
r := &routing.Router{
|
||||||
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
||||||
Logger: routing.NewLogger(brain.URL),
|
Logger: routing.NewLogger(brain.URL, ""),
|
||||||
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
||||||
FastModel: "koala/qwen35-9b-fast",
|
FastModel: "koala/qwen35-9b-fast",
|
||||||
ThinkingModel: "iguana/gemma4-26b",
|
ThinkingModel: "iguana/gemma4-26b",
|
||||||
@@ -117,7 +117,7 @@ func TestRouterDefaultsToFastWhenBrainUnreachable(t *testing.T) {
|
|||||||
llm := &fakeLLM{resp: "ok"}
|
llm := &fakeLLM{resp: "ok"}
|
||||||
r := &routing.Router{
|
r := &routing.Router{
|
||||||
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
Fetcher: routing.NewFetcher(brain.URL, "7d", time.Minute),
|
||||||
Logger: routing.NewLogger(brain.URL),
|
Logger: routing.NewLogger(brain.URL, ""),
|
||||||
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
Policy: routing.Policy{Floor: 0.9, Ceil: 0.7},
|
||||||
FastModel: "koala/qwen35-9b-fast",
|
FastModel: "koala/qwen35-9b-fast",
|
||||||
ThinkingModel: "iguana/gemma4-26b",
|
ThinkingModel: "iguana/gemma4-26b",
|
||||||
|
|||||||
Reference in New Issue
Block a user