Compare commits

...
6 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 cc86a74dc5 fix(ci): stop the tag push from re-deploying, and make the infra bump idempotent
CI / Lint / Test / Vet (push) Successful in 34s
CI / Mirror to GitHub (push) Successful in 4s
Two runs of the full build+deploy fired for every push, and the second died:

    nothing to commit, working tree clean
    ❌  Failure - Main Update infra repo

The deploy was fine both times — the second run had nothing left to do. The
cause is the pair of triggers meeting: ci.yml ran on `push` to main AND on a
`v*` tag, and cd.yml keys off "CI succeeded", so `git push origin main` followed
by `git push origin refs/tags/vX` produced two CI runs for one sha and two
deploys behind them. The tag run carries the same github.sha, so it rebuilt the
same image and then found the infra manifest already pointing at it.

Nothing consumed the tag-triggered run: there is no release workflow, and cd.yml
reads github.sha rather than the tag. Dropping `tags:` removes the duplicate
build outright.

The idempotency guard stays anyway, because a manual re-run has the same shape
and should not ring the CI-red alarm for work that is already done — infra#366
turns a red run into a tracked issue, so a false red costs a real triage.

Closes #118

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 07:09:14 +02:00
mathiasandClaude Opus 5 6e449b464c fix(taskfile): remove build targets for binaries deleted months ago, and make check build (#121)
CI / Lint / Test / Vet (push) Successful in 32s
CI / Mirror to GitHub (push) Successful in 4s
Removes supervisor:dev, supervisor:build, supervisor:test:smoke, smoke:routing and scripts/smoke-routing.sh -- all pointed at cmd/supervisor (deleted c117634, 2026-05-12) or cmd/routing (iceboxed fb59c39, 2026-07-01). Repoints `build` at hyperguild:build + ingestion:build and adds `build` to `check`, so a task target naming a deleted package fails the gate instead of staying invisible to lint/test/vet.

Refs #120. Item 2 of #120 (internal/config has no non-test callers) is deliberately NOT addressed -- #120 stays open for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-16 22:36:49 +00:00
mathias 868fe28c96 fix(config): delete the model-routing chain — nothing has called it since April (#119)
CI / Lint / Test / Vet (push) Successful in 31s
CI / Mirror to GitHub (push) Successful in 4s
Closes #116.

Deletes config/models.yaml, internal/config/models.go (LoadModels, ModelFor),
its test, the ModelsFile field on Config, and SUPERVISOR_MODELS_FILE from
.env.example. Option A from the issue.

Merge-gate evidence: negative claim independently re-established (no Go, shell,
Dockerfile, k8s, CI or infra-repo caller); task check exit 0 across both
modules; CI run 7374 green on the PR head.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-16 22:24:47 +00:00
mathiasandClaude Opus 5 6e69698343 fix(brain): a one-word tunnel title must match the prose case-exactly
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 31s
AutoTunnel matched note titles case-insensitively, so a one-word title linked
every entry that happened to use that word. Measured on the live corpus: 185
entries carry an auto-written See-also link to wiki/telos/decisions/wrong.md
(title "WRONG") and 136 to .../status.md — the two most-linked nodes in the
whole graph, and both of them artifacts of the English language rather than
references. telos's projects/problems/strategies/goals/beliefs entries account
for another ~60 between them.

That pollution is not cosmetic: inbound-link count is what the duplicate
Fixer picks survivors by, what orphan detection reads, and what brain_graph
shows.

One-word titles now need the title's own casing in the prose ("WRONG", "BERT",
"NLP" — an entity name in a sentence still links). Multi-word titles keep the
case-insensitive match: "risk tier middleware" is a real mention however it is
capitalised, and a three-word collision is not an accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:08:40 +02:00
mathiasandClaude Opus 5 eb2acfeeb8 fix(brain): move the wikilink strip into WriteNote/UpdateNote
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 32s
The BrainStore is not the only door. POST /write — the HTTP path CLAUDE.md
hands to curl and scripts — calls api.WriteNote directly, so the strip
shipped one commit ago covered brain_write and capture while leaving the raw
door ungated. A gate one door wide is not a gate.

The BrainStore keeps its own call so it can still report Ref.Unlinked to the
MCP caller; running the strip twice is a no-op on content the primitive has
already cleaned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:59:44 +02:00
mathiasandClaude Opus 5 6e5a9cfce3 fix(brain): unwrap wikilinks that name no entry at the write boundary
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 35s
Every brain write accepted [[targets]] that resolve to nothing — skill
names, repo names, agent-local memory-file slugs — and each one became a
permanent [high] broken-link finding. The brain-audit digest grew 19 -> 63
high findings between 2026-08-26 and 2026-09-16; 53 of the 63 were exactly
this, and none are fixable by brain-gardener's BrokenLinkFixer, which only
repoints edit-distance-1 renames. There is no target to repoint to.

StripUnresolvedLinks resolves each link against knowledge/ + wiki/ using
brain-gardener's own resolver semantics (bare slug or wing/hall/slug path
form, code spans excluded) and unwraps the misses to plain text, reporting
them as Ref.Unlinked / the MCP `unlinked` field.

Unwrap rather than reject: a refused write on claudewatcher's unattended
ingest path is a lost learning, and the author's words are worth keeping
even when the link is not. An empty corpus is a no-op, so a misconfigured
brainDir cannot strip every link in a note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:53:42 +02:00
21 changed files with 499 additions and 248 deletions
-1
View File
@@ -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
+9 -1
View File
@@ -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: |
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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:
-26
View File
@@ -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
+10
View File
@@ -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)
}
+42
View File
@@ -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]]")
}
+8
View File
@@ -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
+157
View File
@@ -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)
}
+22 -4
View File
@@ -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 {
+57 -11
View File
@@ -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)
}
+14 -2
View File
@@ -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]]")
}
+4
View File
@@ -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
+22 -3
View File
@@ -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 {
-2
View File
@@ -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", "")
-49
View File
@@ -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 ""
}
-53
View File
@@ -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"))
}
-64
View File
@@ -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"