Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc86a74dc5 | ||
|
|
6e449b464c | ||
|
|
868fe28c96 | ||
|
|
6e69698343 | ||
|
|
eb2acfeeb8 | ||
|
|
6e5a9cfce3 |
@@ -1,7 +1,6 @@
|
||||
# Supervisor MCP server
|
||||
SUPERVISOR_PORT=3200
|
||||
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
||||
SUPERVISOR_MODELS_FILE=./config/models.yaml
|
||||
|
||||
# LiteLLM gateway (iguana)
|
||||
LITELLM_BASE_URL=http://iguana:4000
|
||||
|
||||
@@ -81,11 +81,19 @@ jobs:
|
||||
git config user.email "cd-bot@d-ma.be"
|
||||
git config user.name "CD Bot"
|
||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||
# Idempotent: `git commit` exits 1 on an empty index, so a run that
|
||||
# finds the bump already applied failed the whole job and rang the
|
||||
# CI-red alarm for a deploy that was fine (hyperguild#118). Two
|
||||
# sources of a no-op run: a manual re-run, and the tag-push CI run
|
||||
# that used to shadow the branch-push one for the same sha.
|
||||
if git diff --cached --quiet; then
|
||||
echo "Infra already at ${IMAGE_TAG} — nothing to commit"
|
||||
else
|
||||
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||
git push
|
||||
|
||||
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
- name: Trigger Flux reconcile (immediate)
|
||||
run: |
|
||||
|
||||
@@ -2,8 +2,13 @@ name: CI
|
||||
|
||||
"on":
|
||||
push:
|
||||
# Branch only. A `v*` tag here points at a commit main already tested, so
|
||||
# the tag push re-ran the whole gate and — because cd.yml keys off "CI
|
||||
# succeeded" — triggered a SECOND full image build and deploy for the same
|
||||
# sha. That second deploy then died on "nothing to commit" (hyperguild#118).
|
||||
# Nothing consumes a tag-triggered run: there is no release workflow, and
|
||||
# cd.yml reads github.sha, not the tag.
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
||||
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 INGEST_SVC_URL=http://localhost:3300 go run ./cmd/supervisor/
|
||||
|
||||
+9
-26
@@ -21,7 +21,7 @@ tasks:
|
||||
# ── Development ────────────────────────────────────────────────────────────
|
||||
|
||||
start:
|
||||
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
|
||||
desc: Start ingestion (requires goreman — go install github.com/mattn/goreman@latest)
|
||||
cmds:
|
||||
- goreman start
|
||||
|
||||
@@ -32,11 +32,6 @@ tasks:
|
||||
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
||||
- echo "hyperguild stopped"
|
||||
|
||||
supervisor:dev:
|
||||
desc: Run supervisor MCP server (development)
|
||||
cmds:
|
||||
- go run ./cmd/supervisor
|
||||
|
||||
hyperguild:dev:
|
||||
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
||||
cmds:
|
||||
@@ -67,14 +62,9 @@ tasks:
|
||||
build:
|
||||
desc: Build all binaries
|
||||
cmds:
|
||||
- task: supervisor:build
|
||||
- task: hyperguild:build
|
||||
- task: ingestion:build
|
||||
|
||||
supervisor:build:
|
||||
desc: Build supervisor binary
|
||||
cmds:
|
||||
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
||||
|
||||
ingestion:build:
|
||||
desc: Build ingestion server binary
|
||||
dir: ingestion
|
||||
@@ -84,7 +74,7 @@ tasks:
|
||||
# ── Quality ────────────────────────────────────────────────────────────────
|
||||
|
||||
check:
|
||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||
desc: Run all checks (context freshness + lint + test + vet + build) across all modules
|
||||
cmds:
|
||||
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
||||
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
||||
@@ -120,6 +110,12 @@ tasks:
|
||||
- task: lint
|
||||
- task: test
|
||||
- task: vet
|
||||
# `build` is in the gate deliberately (hyperguild#120). vet and test walk
|
||||
# ./... , which enumerates the packages that EXIST — so a build target
|
||||
# naming a DELETED package is invisible to both. `task build` pointed at
|
||||
# ./cmd/supervisor for months after that directory was removed, and CI
|
||||
# stayed green the whole time because nothing in check ever built.
|
||||
- task: build
|
||||
- task: security:gitleaks
|
||||
|
||||
# ── Security ─────────────────────────────────────────────
|
||||
@@ -173,19 +169,6 @@ tasks:
|
||||
- govulncheck ./... || true
|
||||
- cd ingestion && go vet ./...
|
||||
|
||||
supervisor:test:smoke:
|
||||
desc: Smoke test supervisor via MCP (requires start running)
|
||||
cmds:
|
||||
- |
|
||||
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
||||
|
||||
smoke:routing:
|
||||
desc: Boot the routing pod against live LiteLLM + brain and verify _routing logs land
|
||||
cmds:
|
||||
- bash scripts/smoke-routing.sh
|
||||
|
||||
# ── Git / Release ──────────────────────────────────────────────────────────
|
||||
|
||||
tag:
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Model selection — first entry per skill is used.
|
||||
# Override per-call by passing model in the MCP tool args.
|
||||
# Model names come from LiteLLM /v1/models (host/name format).
|
||||
|
||||
default_chain:
|
||||
- iguana/qwen3-coder-next
|
||||
|
||||
skills:
|
||||
tdd:
|
||||
chain:
|
||||
- koala/qwen3-coder-30b
|
||||
review:
|
||||
chain:
|
||||
- iguana/devstral
|
||||
debug:
|
||||
chain:
|
||||
- iguana/deepseek-r1-14b
|
||||
spec:
|
||||
chain:
|
||||
- koala/phi4-14b
|
||||
retrospective:
|
||||
chain:
|
||||
- iguana/qwen3-coder-next
|
||||
trainer:
|
||||
chain:
|
||||
- iguana/qwen3-coder-next
|
||||
@@ -133,6 +133,16 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
return "", fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
// In the primitive, not in a caller: POST /write reaches here directly,
|
||||
// without the BrainStore, and a gate one door wide is not a gate.
|
||||
// BrainStore still calls the strip itself to report Ref.Unlinked; the
|
||||
// second pass is a no-op on already-clean content.
|
||||
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
opts.Content = clean
|
||||
|
||||
if opts.Wing != "" && opts.Hall != "" {
|
||||
return writeHallNote(brainDir, opts)
|
||||
}
|
||||
|
||||
@@ -497,3 +497,45 @@ func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
}
|
||||
|
||||
// The HTTP POST /write door reaches WriteNote directly, without the
|
||||
// BrainStore, so the unresolved-wikilink strip has to live in the primitive
|
||||
// or that door stays ungated.
|
||||
func TestWriteNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "See [[target]] and [[gitea-ci]].\n",
|
||||
Filename: "src", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "[[target]]")
|
||||
assert.Contains(t, string(got), "and gitea-ci.")
|
||||
assert.NotContains(t, string(got), "[[gitea-ci]]")
|
||||
}
|
||||
|
||||
func TestUpdateNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rel, _, _, err := api.UpdateNote(dir, api.UpdateNoteOptions{
|
||||
Wing: "a", Hall: "facts", Slug: "n",
|
||||
Content: "Related: [[secret-hygiene]].\n", Reason: "r",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "Related: secret-hygiene.")
|
||||
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
return "", "", "", fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
// Same reason as WriteNote: the strip belongs in the primitive so every
|
||||
// door inherits it.
|
||||
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
opts.Content = clean
|
||||
|
||||
var rel string
|
||||
if opts.Path != "" {
|
||||
rel = opts.Path
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package brain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// wikilinkRE matches [[target]] and [[target|Display Text]].
|
||||
var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`)
|
||||
|
||||
// fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a
|
||||
// syntax example rather than a link. Same exclusion brain-gardener's audit
|
||||
// applies on the read side, so the write gate and the audit agree on what
|
||||
// counts as a link at all.
|
||||
var (
|
||||
fencedCodeRE = regexp.MustCompile("(?s)```.*?```")
|
||||
inlineCodeRE = regexp.MustCompile("`[^`\n]*`")
|
||||
)
|
||||
|
||||
// StripUnresolvedLinks unwraps every [[wikilink]] in content whose target
|
||||
// resolves to no entry under knowledge/ or wiki/, leaving the link's display
|
||||
// text (or its target) as plain prose. It returns the rewritten content and
|
||||
// the sorted, de-duplicated targets it unwrapped.
|
||||
//
|
||||
// WHY UNWRAP RATHER THAN REJECT THE WRITE
|
||||
// Agents write [[...]] around anything that feels related — a skill name, a
|
||||
// repo, an agent-local memory file — and none of those are brain entries.
|
||||
// Every one of those becomes a permanent [high] broken-link finding that no
|
||||
// deterministic Fixer can repair, because there is no target to repoint to:
|
||||
// the brain-audit digest grew from 19 to 63 high findings between 2026-08-26
|
||||
// and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write
|
||||
// instead would push the failure onto claudewatcher's unattended ingest path,
|
||||
// where a refused write is a lost learning. Unwrapping keeps the author's
|
||||
// words, keeps the write, and keeps the graph honest.
|
||||
//
|
||||
// An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir)
|
||||
// returns content unchanged: nothing resolving is a bootstrap state, not
|
||||
// evidence that every target was invented.
|
||||
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
|
||||
index, err := entryIndex(brainDir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(index) == 0 {
|
||||
return content, nil, nil
|
||||
}
|
||||
|
||||
code := codeSpans(content)
|
||||
stripped := map[string]struct{}{}
|
||||
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) {
|
||||
start, end := m[0], m[1]
|
||||
if overlaps(start, end, code) {
|
||||
continue
|
||||
}
|
||||
target := strings.TrimSpace(content[m[2]:m[3]])
|
||||
if _, ok := index[linkKey(target)]; ok {
|
||||
continue
|
||||
}
|
||||
display := target
|
||||
if m[4] >= 0 {
|
||||
if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" {
|
||||
display = d
|
||||
}
|
||||
}
|
||||
b.WriteString(content[last:start])
|
||||
b.WriteString(display)
|
||||
last = end
|
||||
stripped[target] = struct{}{}
|
||||
}
|
||||
if last == 0 {
|
||||
return content, nil, nil
|
||||
}
|
||||
b.WriteString(content[last:])
|
||||
|
||||
targets := make([]string, 0, len(stripped))
|
||||
for t := range stripped {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
return b.String(), targets, nil
|
||||
}
|
||||
|
||||
// linkedTrees are the two trees that hold linkable entries — the same pair
|
||||
// package notes admits under ScopeNotes. Duplicated rather than imported
|
||||
// because that list is unexported there and this is a read-only mirror.
|
||||
var linkedTrees = []string{"knowledge", "wiki"}
|
||||
|
||||
// entryIndex maps every form a wikilink may name an entry by — its bare slug
|
||||
// and its path form (wiki/<wing>/<hall>/<slug>.md -> wing/hall/slug) — to a
|
||||
// present marker. Mirrors brain-gardener's resolver so an accepted link is
|
||||
// one the audit will also resolve.
|
||||
func entryIndex(brainDir string) (map[string]struct{}, error) {
|
||||
index := map[string]struct{}{}
|
||||
for _, dir := range linkedTrees {
|
||||
root := filepath.Join(brainDir, dir)
|
||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", dir, err)
|
||||
}
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(brainDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
|
||||
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// linkKey normalises a link target for index lookup: an exact path form is
|
||||
// tried as written, and anything else falls back to its final segment.
|
||||
func linkKey(target string) string {
|
||||
return strings.TrimSuffix(target, ".md")
|
||||
}
|
||||
|
||||
// codeSpans returns the byte ranges of fenced blocks and inline code spans.
|
||||
func codeSpans(content string) [][2]int {
|
||||
var spans [][2]int
|
||||
for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) {
|
||||
spans = append(spans, [2]int{m[0], m[1]})
|
||||
}
|
||||
for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) {
|
||||
spans = append(spans, [2]int{m[0], m[1]})
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// overlaps reports whether [start,end) intersects any span.
|
||||
func overlaps(start, end int, spans [][2]int) bool {
|
||||
for _, s := range spans {
|
||||
if start < s[1] && end > s[0] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package brain_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// seedCorpus writes the two entries every linkcheck case resolves against.
|
||||
func seedCorpus(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
seedNote(t, dir, "wiki/homelab/facts/real-entry.md",
|
||||
"---\nwing: homelab\nhall: facts\ntitle: 'Real entry'\n---\n\nbody.\n")
|
||||
seedNote(t, dir, "knowledge/legacy-entry.md",
|
||||
"---\ntitle: 'Legacy entry'\n---\n\nbody.\n")
|
||||
}
|
||||
|
||||
func TestStripUnresolvedLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
stripped []string
|
||||
}{
|
||||
{
|
||||
name: "bare slug that resolves is kept",
|
||||
body: "See [[real-entry]] for the rule.",
|
||||
want: "See [[real-entry]] for the rule.",
|
||||
},
|
||||
{
|
||||
name: "path-style target that resolves is kept",
|
||||
body: "See [[homelab/facts/real-entry]].",
|
||||
want: "See [[homelab/facts/real-entry]].",
|
||||
},
|
||||
{
|
||||
name: "knowledge/ entry resolves by bare slug",
|
||||
body: "See [[legacy-entry]].",
|
||||
want: "See [[legacy-entry]].",
|
||||
},
|
||||
{
|
||||
name: "unresolvable target is unwrapped to plain text",
|
||||
body: "Related: [[secret-hygiene]].",
|
||||
want: "Related: secret-hygiene.",
|
||||
stripped: []string{"secret-hygiene"},
|
||||
},
|
||||
{
|
||||
name: "unresolvable aliased target keeps its display text",
|
||||
body: "Related: [[feedback_check_host_not_assume|check the host]].",
|
||||
want: "Related: check the host.",
|
||||
stripped: []string{"feedback_check_host_not_assume"},
|
||||
},
|
||||
{
|
||||
name: "link inside a fenced code block is left alone",
|
||||
body: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||
want: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||
},
|
||||
{
|
||||
name: "link inside an inline code span is left alone",
|
||||
body: "Write `[[wing/hall/slug]]` to link.",
|
||||
want: "Write `[[wing/hall/slug]]` to link.",
|
||||
},
|
||||
{
|
||||
name: "every unresolvable target is reported once, sorted",
|
||||
body: "[[zeta-gap]] and [[alpha-gap]] and [[zeta-gap]] again.",
|
||||
want: "zeta-gap and alpha-gap and zeta-gap again.",
|
||||
stripped: []string{"alpha-gap", "zeta-gap"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedCorpus(t, dir)
|
||||
|
||||
got, stripped, err := brain.StripUnresolvedLinks(dir, tc.body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
assert.Equal(t, tc.stripped, stripped)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUnresolvedLinks_MissingBrainDirIsNotAGate(t *testing.T) {
|
||||
// A brain dir with no knowledge//wiki/ tree yet must not turn every link
|
||||
// into an unresolved one — an empty corpus is a bootstrap state, not
|
||||
// evidence that the author invented the target.
|
||||
got, stripped, err := brain.StripUnresolvedLinks(t.TempDir(), "See [[real-entry]].")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "See [[real-entry]].", got)
|
||||
assert.Nil(t, stripped)
|
||||
}
|
||||
@@ -54,11 +54,24 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
||||
return nil
|
||||
}
|
||||
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
||||
needle := strings.ToLower(strings.TrimSpace(title))
|
||||
if needle == "" {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil
|
||||
}
|
||||
idx := strings.Index(lowerContent, needle)
|
||||
|
||||
// A one-word title has to match the prose case-exactly. Matched
|
||||
// case-insensitively, it links every entry that happens to use the
|
||||
// word: "WRONG" (wiki/telos/decisions/wrong.md) collected 185 inbound
|
||||
// See-also links and "Status" 136, making the two most-linked nodes in
|
||||
// the graph artifacts of the English language rather than references.
|
||||
// Multi-word titles keep the case-insensitive match — "risk tier
|
||||
// middleware" in prose is a real mention however it is capitalised,
|
||||
// and a three-word collision is not an accident.
|
||||
haystack, needle := lowerContent, strings.ToLower(title)
|
||||
if isSingleWord(title) {
|
||||
haystack, needle = content, title
|
||||
}
|
||||
idx := strings.Index(haystack, needle)
|
||||
if idx == -1 {
|
||||
return nil
|
||||
}
|
||||
@@ -69,7 +82,7 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
||||
out = append(out, TunnelCandidate{
|
||||
TargetPath: filepath.ToSlash(rel),
|
||||
MatchedTerm: title,
|
||||
Exact: isWholeWord(lowerContent, idx, len(needle)),
|
||||
Exact: isWholeWord(haystack, idx, len(needle)),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -79,6 +92,11 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isSingleWord reports whether title is one whitespace-free token.
|
||||
func isSingleWord(title string) bool {
|
||||
return !strings.ContainsAny(title, " \t")
|
||||
}
|
||||
|
||||
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
||||
// bounded by non-alphanumeric characters (or string edges).
|
||||
func isWholeWord(s string, idx, n int) bool {
|
||||
|
||||
@@ -102,16 +102,29 @@ func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
|
||||
|
||||
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||
"---\ntitle: Routing\n---\nbody.\n")
|
||||
seedNote(t, dir, "wiki/x/facts/routing-table.md",
|
||||
"---\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||
|
||||
// Substring of title appears in content, but not as a whole word.
|
||||
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
||||
candidates, err := brain.DetectTunnels(dir, "the routing tables were rebuilt")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, candidates, 1)
|
||||
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
||||
}
|
||||
|
||||
// A one-word title whose only appearance is lowercase inside another word is
|
||||
// not even a candidate: it would fill the review file with English rather than
|
||||
// with references.
|
||||
func TestDetectTunnels_SingleWordSubstringIsNotACandidate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||
"---\ntitle: Routing\n---\nbody.\n")
|
||||
|
||||
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, candidates)
|
||||
}
|
||||
|
||||
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
||||
@@ -126,20 +139,21 @@ func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
||||
|
||||
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Existing note in a different wing whose title is "Routing".
|
||||
seedNote(t, dir, "wiki/other/facts/routing.md",
|
||||
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
|
||||
// Source note in another wing whose body mentions "rerouting" (substring match only).
|
||||
// Existing note in a different wing whose title is "Routing Table".
|
||||
seedNote(t, dir, "wiki/other/facts/routing-table.md",
|
||||
"---\nwing: other\nhall: facts\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||
// Source note in another wing whose body mentions "routing tables"
|
||||
// (substring match only — the title is not there as a whole word).
|
||||
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
||||
"---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n")
|
||||
"---\nwing: jepa-fx\nhall: facts\n---\nrouting tables rebuilt\n")
|
||||
|
||||
require.NoError(t, brain.AutoTunnel(dir,
|
||||
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
|
||||
"wiki/jepa-fx/facts/new.md", "routing tables rebuilt"))
|
||||
|
||||
// Source must not get auto-linked (fuzzy).
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(got), "[[other/facts/routing]]")
|
||||
assert.NotContains(t, string(got), "[[other/facts/routing-table]]")
|
||||
|
||||
// Candidates file must list the pair.
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
||||
@@ -148,7 +162,7 @@ func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
||||
body, err := os.ReadFile(matches[0])
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
||||
assert.Contains(t, string(body), "wiki/other/facts/routing.md")
|
||||
assert.Contains(t, string(body), "wiki/other/facts/routing-table.md")
|
||||
assert.Contains(t, string(body), "Routing")
|
||||
}
|
||||
|
||||
@@ -175,3 +189,35 @@ func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) {
|
||||
assert.Contains(t, s, "[[a/facts/old]]")
|
||||
assert.Contains(t, s, "[[b/facts/y]]")
|
||||
}
|
||||
|
||||
// A one-word title matched case-insensitively links every entry that happens
|
||||
// to use that word. Live cost before this rule: 185 entries auto-linked to
|
||||
// wiki/telos/decisions/wrong.md (title "WRONG") and 136 to .../status.md,
|
||||
// making the two most-linked nodes in the graph pure artifacts.
|
||||
func TestDetectTunnels_SingleWordTitleNeedsAnExactCaseMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedNote(t, dir, "wiki/telos/decisions/wrong.md",
|
||||
"---\nwing: telos\nhall: decisions\ntitle: WRONG\n---\n# WRONG\n\nRetired beliefs.\n")
|
||||
|
||||
got, err := brain.DetectTunnels(dir, "The obvious fix was to reject the write. Wrong here.")
|
||||
require.NoError(t, err)
|
||||
for _, c := range got {
|
||||
assert.False(t, c.Exact, "a lowercase prose word must not auto-link a one-word title: %+v", c)
|
||||
}
|
||||
|
||||
got, err = brain.DetectTunnels(dir, "The beliefs listed under WRONG were retired.")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.True(t, got[0].Exact, "the title's own casing appearing in prose is a real reference")
|
||||
}
|
||||
|
||||
func TestDetectTunnels_MultiWordTitleStaysCaseInsensitive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedNote(t, dir, "wiki/homelab/facts/risk-tier.md",
|
||||
"---\nwing: homelab\nhall: facts\ntitle: 'Risk Tier Middleware'\n---\nbody\n")
|
||||
|
||||
got, err := brain.DetectTunnels(dir, "We rebuilt the risk tier middleware last week.")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.True(t, got[0].Exact)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ func (s *Store) WithGraph(g graphsync.Store) *Store {
|
||||
|
||||
// Write creates a brain note and returns its read-after-write handle.
|
||||
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
n.Content = content
|
||||
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Content: n.Content,
|
||||
Filename: n.Filename,
|
||||
@@ -57,13 +63,19 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error)
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
|
||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Unlinked: unlinked}, nil
|
||||
}
|
||||
|
||||
// Update supersedes an existing note in place. slug may be a bare slug
|
||||
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||
// contains a slash). It never creates — a missing target is an error.
|
||||
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
n.Content = content
|
||||
|
||||
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||
if strings.Contains(slug, "/") {
|
||||
opts.Path = slug
|
||||
@@ -80,7 +92,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_update", relPath)
|
||||
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true, Unlinked: unlinked}, nil
|
||||
}
|
||||
|
||||
// Get fetches a note by id/path — the read-after-write confirmation
|
||||
|
||||
@@ -83,3 +83,45 @@ func TestStoreGetRoundTripsHash(t *testing.T) {
|
||||
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||
assert.Contains(t, note.Body, "# Body")
|
||||
}
|
||||
|
||||
func TestStoreWriteUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
_, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ref, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "See [[target]] and [[secret-hygiene]].\n",
|
||||
Filename: "src", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"secret-hygiene"}, ref.Unlinked)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/src.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "[[target]]")
|
||||
assert.Contains(t, string(got), "and secret-hygiene.")
|
||||
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||
}
|
||||
|
||||
func TestStoreUpdateUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
_, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||
Content: "Related: [[cad-babysit]].\n", Wing: "a", Hall: "facts", Reason: "r",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"cad-babysit"}, ref.Unlinked)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "Related: cad-babysit.")
|
||||
assert.NotContains(t, string(got), "[[cad-babysit]]")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ type Ref struct {
|
||||
Path string
|
||||
ContentHash string
|
||||
Superseded bool
|
||||
// Unlinked names the [[wikilink]] targets the store unwrapped to plain
|
||||
// text because they resolve to no entry. Reported rather than rejected:
|
||||
// see brain.StripUnresolvedLinks for why the write still lands.
|
||||
Unlinked []string
|
||||
}
|
||||
|
||||
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||
|
||||
@@ -233,6 +233,15 @@ type brainWriteArgs struct {
|
||||
Hall string `json:"hall,omitempty"`
|
||||
}
|
||||
|
||||
// unlinkedNote explains the `unlinked` field to the caller that caused it.
|
||||
// The write landed; what is gone is the [[...]] around a target that names no
|
||||
// brain entry. Said here rather than only in a doc comment because the caller
|
||||
// is a model that will otherwise repeat it on the next write.
|
||||
const unlinkedNote = "these [[wikilink]] targets resolve to no brain entry, " +
|
||||
"so they were unwrapped to plain text. Link only entries that exist " +
|
||||
"(a filename under knowledge/ or wiki/); write the target entry first if " +
|
||||
"you want the link, and keep skills, repos and agent-local memory files as prose."
|
||||
|
||||
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainWriteArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
@@ -253,7 +262,12 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
||||
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}
|
||||
if len(ref.Unlinked) > 0 {
|
||||
out["unlinked"] = ref.Unlinked
|
||||
out["unlinked_note"] = unlinkedNote
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
type brainUpdateArgs struct {
|
||||
@@ -297,9 +311,14 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
out := map[string]any{
|
||||
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||
})
|
||||
}
|
||||
if len(ref.Unlinked) > 0 {
|
||||
out["unlinked"] = ref.Unlinked
|
||||
out["unlinked_note"] = unlinkedNote
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
type brainGetArgs struct {
|
||||
|
||||
@@ -7,7 +7,6 @@ type Config struct {
|
||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default http://iguana:4000
|
||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
||||
ModelsFile string // SUPERVISOR_MODELS_FILE, default <ConfigDir>/../models.yaml
|
||||
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
|
||||
IngestSvcURL string // INGEST_SVC_URL — base URL for brain_ingest (/ingest, /ingest-path)
|
||||
KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search
|
||||
@@ -23,7 +22,6 @@ func Load() (Config, error) {
|
||||
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
||||
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
||||
}
|
||||
cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml")
|
||||
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
|
||||
cfg.IngestSvcURL = envOr("INGEST_SVC_URL", "")
|
||||
cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "")
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type skillChain struct {
|
||||
Chain []string `yaml:"chain"`
|
||||
}
|
||||
|
||||
type modelsFile struct {
|
||||
DefaultChain []string `yaml:"default_chain"`
|
||||
Skills map[string]skillChain `yaml:"skills"`
|
||||
}
|
||||
|
||||
type Models struct {
|
||||
data modelsFile
|
||||
}
|
||||
|
||||
func LoadModels(path string) (Models, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Models{}, fmt.Errorf("load models: %w", err)
|
||||
}
|
||||
var f modelsFile
|
||||
if err := yaml.Unmarshal(raw, &f); err != nil {
|
||||
return Models{}, fmt.Errorf("parse models: %w", err)
|
||||
}
|
||||
return Models{data: f}, nil
|
||||
}
|
||||
|
||||
// ModelFor returns the primary model to use for a skill.
|
||||
// If override is non-empty, it is returned directly.
|
||||
// Falls back to default_chain[0] when the skill has no explicit entry.
|
||||
func (m Models) ModelFor(skill, override string) string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
if sc, ok := m.data.Skills[skill]; ok && len(sc.Chain) > 0 {
|
||||
return sc.Chain[0]
|
||||
}
|
||||
if len(m.data.DefaultChain) > 0 {
|
||||
return m.data.DefaultChain[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.d-ma.be/mathias/hyperguild/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testYAML = `
|
||||
default_chain:
|
||||
- ollama/qwen3-coder-30b-tuned
|
||||
- claude-sonnet-4-6
|
||||
|
||||
skills:
|
||||
review:
|
||||
chain:
|
||||
- ollama/devstral-tuned
|
||||
- ollama/gemma4
|
||||
- claude-sonnet-4-6
|
||||
spec:
|
||||
chain:
|
||||
- ollama/phi4
|
||||
- claude-opus-4-6
|
||||
`
|
||||
|
||||
func writeModels(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "models.yaml")
|
||||
require.NoError(t, os.WriteFile(f, []byte(content), 0o644))
|
||||
return f
|
||||
}
|
||||
|
||||
func TestModelsModelForSkillWithEntry(t *testing.T) {
|
||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ollama/devstral-tuned", m.ModelFor("review", ""))
|
||||
}
|
||||
|
||||
func TestModelsModelForDefaultFallback(t *testing.T) {
|
||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ollama/qwen3-coder-30b-tuned", m.ModelFor("trainer", ""))
|
||||
}
|
||||
|
||||
func TestModelsModelForCallerOverride(t *testing.T) {
|
||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "claude-opus-4-6", m.ModelFor("review", "claude-opus-4-6"))
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Boot the routing binary and exercise its four tools against live deps.
|
||||
# Skipped when LITELLM_BASE_URL or BRAIN_URL is unreachable.
|
||||
|
||||
LITELLM_BASE_URL="${LITELLM_BASE_URL:-https://llm-api.d-ma.be}"
|
||||
BRAIN_URL="${BRAIN_URL:-http://koala:30330}"
|
||||
|
||||
if ! curl -sS --max-time 2 "${LITELLM_BASE_URL}/v1/models" >/dev/null 2>&1; then
|
||||
echo "SKIP: LITELLM at ${LITELLM_BASE_URL} unreachable"
|
||||
exit 0
|
||||
fi
|
||||
if ! curl -sS --max-time 2 "${BRAIN_URL}/query" -X POST -d '{"query":"x","k":1}' -H 'Content-Type: application/json' >/dev/null 2>&1; then
|
||||
echo "SKIP: BRAIN at ${BRAIN_URL} unreachable"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PORT=33310
|
||||
BIN=$(mktemp)
|
||||
trap 'rm -f $BIN; pkill -P $$ -f "$BIN" 2>/dev/null || true' EXIT
|
||||
|
||||
go build -o "$BIN" ./cmd/routing
|
||||
|
||||
LITELLM_BASE_URL="$LITELLM_BASE_URL" BRAIN_URL="$BRAIN_URL" \
|
||||
ROUTING_PORT="$PORT" SUPERVISOR_CONFIG_DIR="$(pwd)/config/supervisor" \
|
||||
"$BIN" &
|
||||
|
||||
# Wait for the binary to bind.
|
||||
for _ in $(seq 1 50); do
|
||||
curl -sS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
call_tool() {
|
||||
local tool="$1"
|
||||
local args="$2"
|
||||
curl -sS -X POST "http://127.0.0.1:${PORT}/mcp" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \
|
||||
| jq -e '.result // .error' > /dev/null
|
||||
}
|
||||
|
||||
echo "calling tools/list..."
|
||||
curl -sS -X POST "http://127.0.0.1:${PORT}/mcp" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
|
||||
| jq -r '.result.tools | map(.name) | sort | .[]'
|
||||
|
||||
echo "calling each tool..."
|
||||
call_tool review '{"project_root":"/tmp","files":["README.md"],"session_id":"smoke-1"}'
|
||||
call_tool debug '{"project_root":"/tmp","error":"smoke test","session_id":"smoke-1"}'
|
||||
call_tool retrospective '{"session_id":"smoke-1"}'
|
||||
call_tool trainer '{"session_id":"smoke-1"}'
|
||||
|
||||
echo "checking brain has _routing entries..."
|
||||
sleep 2
|
||||
COUNT=$(curl -sS "${BRAIN_URL}/pass-rate?skill=_routing&window=1h" | jq -r '.total // 0')
|
||||
if [ "${COUNT}" -lt 4 ]; then
|
||||
echo "FAIL: expected >=4 _routing entries in last 1h, got ${COUNT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: smoke:routing"
|
||||
Reference in New Issue
Block a user