Compare commits
10 Commits
23dd355b8a
...
99d523189f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d523189f | ||
|
|
2d219760e5 | ||
|
|
4bf5edb78e | ||
|
|
98acf1c14e | ||
|
|
9741d8ba28 | ||
|
|
bf67299a48 | ||
|
|
24d9216474 | ||
|
|
344def20bb | ||
|
|
d084af1af0 | ||
|
|
e98bb2ba65 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"supervisor": {
|
"supervisor": {
|
||||||
"url": "http://localhost:3200/mcp",
|
"url": "http://localhost:3200/mcp",
|
||||||
"description": "Skill workers — TDD (red/green/refactor), more coming"
|
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ SUPERVISOR_MODELS_FILE=./config/models.yaml
|
|||||||
# LiteLLM gateway (iguana)
|
# LiteLLM gateway (iguana)
|
||||||
LITELLM_BASE_URL=http://iguana:4000
|
LITELLM_BASE_URL=http://iguana:4000
|
||||||
LITELLM_API_KEY=your-litellm-master-key
|
LITELLM_API_KEY=your-litellm-master-key
|
||||||
|
|
||||||
|
# Ingestion server
|
||||||
|
INGEST_BASE_URL=http://localhost:3300
|
||||||
|
INGEST_PORT=3300
|
||||||
|
INGEST_BRAIN_DIR=./brain
|
||||||
|
|
||||||
|
# Brain directories
|
||||||
|
SUPERVISOR_SESSIONS_DIR=./brain/sessions
|
||||||
|
SUPERVISOR_BRAIN_DIR=./brain
|
||||||
|
|||||||
58
.gitea/workflows/ci.yml
Normal file
58
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── 1. Quality gate ─────────────────────────────────────────────────────────
|
||||||
|
check:
|
||||||
|
name: Lint / Test / Vet
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false # self-hosted: Go cache persists on disk between runs
|
||||||
|
|
||||||
|
- name: Verify toolchain
|
||||||
|
run: |
|
||||||
|
go version
|
||||||
|
task --version
|
||||||
|
govulncheck -version 2>&1 || true
|
||||||
|
|
||||||
|
- name: Install golangci-lint
|
||||||
|
run: |
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \
|
||||||
|
| sh -s -- -b "$(go env GOPATH)/bin" v2.11.4
|
||||||
|
golangci-lint --version
|
||||||
|
|
||||||
|
- name: Run checks
|
||||||
|
run: task check
|
||||||
|
|
||||||
|
# ── 2. Mirror to GitHub ─────────────────────────────────────────────────────
|
||||||
|
mirror:
|
||||||
|
name: Mirror to GitHub
|
||||||
|
needs: check
|
||||||
|
runs-on: self-hosted
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to GitHub
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo '${{ secrets.GH_DEPLOY_KEY }}' > ~/.ssh/id_rsa_gh_mirror
|
||||||
|
chmod 600 ~/.ssh/id_rsa_gh_mirror
|
||||||
|
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
|
||||||
|
git push git@github.com:mathiasb/hyperguild.git HEAD:main --tags
|
||||||
|
rm ~/.ssh/id_rsa_gh_mirror
|
||||||
|
echo "✓ Mirrored to GitHub"
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ secrets/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Brainstorm sessions
|
||||||
|
.superpowers/
|
||||||
|
|||||||
2
Procfile
Normal file
2
Procfile
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 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 go run ./cmd/supervisor/
|
||||||
165
README.md
165
README.md
@@ -1,98 +1,109 @@
|
|||||||
# Project template
|
# hyperguild
|
||||||
|
|
||||||
Harness-agnostic project scaffold using the Agent Skills open standard.
|
An MCP server that acts as a disciplined AI supervisor for Claude Code sessions.
|
||||||
|
Instead of letting Claude Code do whatever it wants, hyperguild enforces structured
|
||||||
|
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||||
|
into a searchable brain.
|
||||||
|
|
||||||
## Quick start
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
Your Claude Code session (in any project)
|
||||||
|
│
|
||||||
|
│ MCP tools (over stdio bridge → HTTP)
|
||||||
|
▼
|
||||||
|
supervisor :3200 — skill workers: tdd, retrospective
|
||||||
|
ingestion :3300 — brain HTTP API: query wiki, write notes
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
brain/
|
||||||
|
├── sessions/ — JSONL log, one file per session_id
|
||||||
|
├── wiki/ — searchable knowledge (full-text)
|
||||||
|
│ ├── concepts/
|
||||||
|
│ ├── entities/
|
||||||
|
│ └── sources/
|
||||||
|
├── raw/ — retrospective output, staged for review
|
||||||
|
└── training-data/ — SFT/DPO/RL data (Phase 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
|
| Tool | What it does |
|
||||||
|
|------|-------------|
|
||||||
|
| `tdd_red` | Writes a failing test for a spec, verifies it fails |
|
||||||
|
| `tdd_green` | Writes the minimal implementation to make tests pass |
|
||||||
|
| `tdd_refactor` | Cleans up implementation while keeping tests green |
|
||||||
|
| `session_log` | Appends a structured entry to the session JSONL log |
|
||||||
|
| `retrospective` | Reads the session log, identifies novel learnings, writes to brain/raw/ |
|
||||||
|
| `brain_query` | Full-text search over brain/wiki/ |
|
||||||
|
| `brain_write` | Writes a note to brain/raw/ (with optional YAML frontmatter) |
|
||||||
|
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||||
|
|
||||||
|
## Start the servers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
degit mathias/project-template my-new-project
|
# Requires goreman: go install github.com/mattn/goreman@latest
|
||||||
cd my-new-project
|
task start # starts ingestion (:3300) + supervisor (:3200) via goreman
|
||||||
task init
|
task stop # kills both by port
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structure
|
## Connect a project
|
||||||
|
|
||||||
```
|
Create `.mcp.json` in your project root:
|
||||||
.context/
|
|
||||||
├── PROJECT.md ← Canonical project context (edit this)
|
|
||||||
├── mcp.json ← MCP server config (generated on first sync)
|
|
||||||
└── system-prompt.txt ← Generated: generic system prompt
|
|
||||||
|
|
||||||
.skills/
|
```json
|
||||||
├── go-patterns/
|
{
|
||||||
│ └── SKILL.md ← Agent Skills standard format
|
"mcpServers": {
|
||||||
└── htmx-patterns/
|
"supervisor": {
|
||||||
└── SKILL.md
|
"command": "/Users/mathias/dev/AI/supervisor/bin/supervisor-bridge",
|
||||||
|
"env": {
|
||||||
scripts/
|
"SUPERVISOR_URL": "http://localhost:3200/mcp"
|
||||||
└── context-sync.sh ← Adapter generator (finds root AGENT.md automatically)
|
}
|
||||||
|
}
|
||||||
Taskfile.yml ← Task runner config
|
}
|
||||||
DECISIONS.md ← Why things are the way they are
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Generated files (gitignored)
|
Build the bridge binary once: `task bridge:build`
|
||||||
|
|
||||||
| File | Consumer | Notes |
|
Then open Claude Code in your project — run `/mcp` to confirm `supervisor` is listed.
|
||||||
|------|----------|-------|
|
|
||||||
| `AGENTS.md` | Crush, Pi, Antigravity | Root + project concatenated |
|
|
||||||
| `CLAUDE.md` | Claude Code | Project-only (inherits root via tree walk) |
|
|
||||||
| `.cursorrules` | Cursor | Root + project concatenated |
|
|
||||||
| `.aider.conventions.md` | Aider | Root + project concatenated |
|
|
||||||
| `.context/system-prompt.txt` | Open WebUI, Mods, generic | Root + project concatenated |
|
|
||||||
|
|
||||||
## How root context works
|
## A typical TDD session
|
||||||
|
|
||||||
The script walks up from the project directory looking for `~/dev/.context/AGENT.md`.
|
```
|
||||||
|
1. Call tdd_red → spec in, failing test file out
|
||||||
- **Claude Code**: inherits natively (reads every `CLAUDE.md` up the tree) → project CLAUDE.md is project-only
|
2. Call tdd_green → test path in, implementation out
|
||||||
- **Everything else**: can't walk the tree → script concatenates root + project into each generated file
|
3. Call tdd_refactor → impl + test in, cleaned code out
|
||||||
|
4. Call session_log → log each phase result
|
||||||
## Skills
|
5. Call retrospective → extracts learnings → brain/raw/
|
||||||
|
6. Review brain/raw/, move worthy notes to brain/wiki/concepts/
|
||||||
Skills use the [Agent Skills open standard](https://github.com/badlogic/pi-skills). Each skill is a folder with a `SKILL.md` containing frontmatter:
|
7. Future sessions: call brain_query to retrieve relevant context
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: my-skill
|
|
||||||
description: What this skill does. When to use it.
|
|
||||||
---
|
|
||||||
# Instructions here
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported natively by Claude Code, Pi, Crush, and Antigravity. No adapter needed for skills.
|
## Tier detection
|
||||||
|
|
||||||
### Adding a skill
|
The supervisor probes connectivity at call time:
|
||||||
|
|
||||||
```bash
|
| Tier | Label | Condition |
|
||||||
mkdir .skills/my-new-skill
|
|------|-------|-----------|
|
||||||
# Create .skills/my-new-skill/SKILL.md with frontmatter + instructions
|
| 1 | full-online | Can reach api.anthropic.com |
|
||||||
```
|
| 2 | lan-only | Can reach LiteLLM but not Anthropic |
|
||||||
|
| 3 | airplane | No external connectivity |
|
||||||
|
|
||||||
### Using pi-skills (cross-compatible)
|
## Key env vars
|
||||||
|
|
||||||
```bash
|
| Variable | Default | Purpose |
|
||||||
# User-level (all projects)
|
|----------|---------|---------|
|
||||||
git clone https://github.com/badlogic/pi-skills ~/.pi/agent/skills/pi-skills
|
| `INGEST_BRAIN_DIR` | `../brain` | Brain directory for ingestion server |
|
||||||
|
| `INGEST_PORT` | `3300` | Ingestion server port |
|
||||||
|
| `SUPERVISOR_CONFIG_DIR` | `./config/supervisor` | Skill discipline files |
|
||||||
|
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
||||||
|
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
||||||
|
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
|
||||||
|
|
||||||
# Symlink for Claude Code
|
## Phase 2 (planned)
|
||||||
ln -s ~/.pi/agent/skills/pi-skills/brave-search ~/.claude/skills/brave-search
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage with specific tools
|
- `review` skill — structured code review with iron law enforcement
|
||||||
|
- `debug` skill — hypothesis-driven debugging sessions
|
||||||
**Claude Code**: `task context:sync:claude` → reads `CLAUDE.md` + discovers `.skills/*/SKILL.md`
|
- `spec` skill — generates specs from conversations
|
||||||
|
- `trainer` — extracts SFT/DPO pairs from session logs for fine-tuning
|
||||||
**Crush**: `task context:sync:agents` → reads `AGENTS.md` + discovers `.skills/*/SKILL.md`
|
|
||||||
|
|
||||||
**Pi**: `task context:sync:agents` → reads `AGENTS.md` + discovers `.skills/*/SKILL.md` (or symlink `.skills/` to `.pi/skills/`)
|
|
||||||
|
|
||||||
**Antigravity**: `task context:sync:agents` → reads `AGENTS.md` + discovers `.skills/*/SKILL.md`
|
|
||||||
|
|
||||||
**Cursor**: `task context:sync:cursor` → reads `.cursorrules`
|
|
||||||
|
|
||||||
**Mistral Vibe**: Run root-level `task context:sync:vibe` once → `vibe --agent mathias`
|
|
||||||
|
|
||||||
**Open WebUI / Mods**: Copy `.context/system-prompt.txt` into a preset or pipe it
|
|
||||||
|
|
||||||
**Any other tool**: Point at `.context/PROJECT.md` directly — it's human-readable markdown
|
|
||||||
|
|||||||
126
Taskfile.yml
126
Taskfile.yml
@@ -1,7 +1,11 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
PROJECT_NAME: '{{.PROJECT_NAME | default "myproject"}}'
|
PROJECT_NAME: hyperguild
|
||||||
|
VERSION:
|
||||||
|
sh: git describe --tags --always --dirty 2>/dev/null || echo "dev"
|
||||||
|
SHORT_SHA:
|
||||||
|
sh: git rev-parse --short HEAD
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
context:sync:
|
context:sync:
|
||||||
@@ -19,57 +23,109 @@ tasks:
|
|||||||
context:sync:cursor:
|
context:sync:cursor:
|
||||||
cmds: [bash scripts/context-sync.sh cursor]
|
cmds: [bash scripts/context-sync.sh cursor]
|
||||||
|
|
||||||
dev:
|
# ── Development ────────────────────────────────────────────────────────────
|
||||||
desc: Start development server
|
|
||||||
cmds:
|
|
||||||
- go run ./cmd/server
|
|
||||||
|
|
||||||
build:
|
start:
|
||||||
desc: Build the binary
|
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
|
||||||
cmds:
|
cmds:
|
||||||
- go build -o bin/{{.PROJECT_NAME}} ./cmd/server
|
- goreman start
|
||||||
|
|
||||||
check:
|
stop:
|
||||||
desc: Run all checks (lint + test + vet)
|
desc: Stop all hyperguild processes (Ctrl-C in the goreman terminal, or kill by port)
|
||||||
cmds:
|
cmds:
|
||||||
- task: lint
|
- lsof -ti:3300 | xargs kill -9 2>/dev/null || true
|
||||||
- task: test
|
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
||||||
- task: vet
|
- echo "hyperguild stopped"
|
||||||
|
|
||||||
lint:
|
|
||||||
cmds: [golangci-lint run ./...]
|
|
||||||
test:
|
|
||||||
cmds: [go test -race -count=1 ./...]
|
|
||||||
vet:
|
|
||||||
cmds:
|
|
||||||
- go vet ./...
|
|
||||||
- govulncheck ./... || true
|
|
||||||
|
|
||||||
up:
|
|
||||||
desc: Start containers
|
|
||||||
cmds: [docker compose up -d]
|
|
||||||
down:
|
|
||||||
cmds: [docker compose down]
|
|
||||||
|
|
||||||
init:
|
|
||||||
desc: Initialize a new project from this template
|
|
||||||
cmds:
|
|
||||||
- bash scripts/init.sh
|
|
||||||
|
|
||||||
supervisor:dev:
|
supervisor:dev:
|
||||||
desc: Run supervisor MCP server (development)
|
desc: Run supervisor MCP server (development)
|
||||||
cmds:
|
cmds:
|
||||||
- go run ./cmd/supervisor
|
- go run ./cmd/supervisor
|
||||||
|
|
||||||
|
ingestion:dev:
|
||||||
|
desc: Run ingestion server in development mode
|
||||||
|
dir: ingestion
|
||||||
|
env:
|
||||||
|
INGEST_BRAIN_DIR: "{{.ROOT_DIR}}/brain"
|
||||||
|
INGEST_PORT: "3300"
|
||||||
|
cmds:
|
||||||
|
- go run ./cmd/server
|
||||||
|
|
||||||
|
# ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build all binaries
|
||||||
|
cmds:
|
||||||
|
- task: supervisor:build
|
||||||
|
- task: bridge:build
|
||||||
|
- task: ingestion:build
|
||||||
|
|
||||||
supervisor:build:
|
supervisor:build:
|
||||||
desc: Build supervisor binary
|
desc: Build supervisor binary
|
||||||
cmds:
|
cmds:
|
||||||
- go build -o bin/supervisor ./cmd/supervisor
|
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
||||||
|
|
||||||
|
bridge:build:
|
||||||
|
desc: Build stdio↔HTTP bridge for Claude Code MCP integration
|
||||||
|
cmds:
|
||||||
|
- go build -trimpath -ldflags="-s -w" -o bin/supervisor-bridge ./cmd/bridge
|
||||||
|
|
||||||
|
ingestion:build:
|
||||||
|
desc: Build ingestion server binary
|
||||||
|
dir: ingestion
|
||||||
|
cmds:
|
||||||
|
- go build -trimpath -ldflags="-s -w" -o ../bin/ingestion-server ./cmd/server
|
||||||
|
|
||||||
|
# ── Quality ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
check:
|
||||||
|
desc: Run all checks (lint + test + vet) across all modules
|
||||||
|
cmds:
|
||||||
|
- task: lint
|
||||||
|
- task: test
|
||||||
|
- task: vet
|
||||||
|
|
||||||
|
lint:
|
||||||
|
cmds:
|
||||||
|
- golangci-lint run ./...
|
||||||
|
- cd ingestion && golangci-lint run ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
cmds:
|
||||||
|
- go test -race -count=1 ./...
|
||||||
|
- cd ingestion && go test -race -count=1 ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
cmds:
|
||||||
|
- go vet ./...
|
||||||
|
- govulncheck ./... || true
|
||||||
|
- cd ingestion && go vet ./...
|
||||||
|
|
||||||
supervisor:test:smoke:
|
supervisor:test:smoke:
|
||||||
desc: Smoke test supervisor via MCP (requires supervisor:dev running)
|
desc: Smoke test supervisor via MCP (requires start running)
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
||||||
|
|
||||||
|
# ── Git / Release ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tag:
|
||||||
|
desc: Create and push a semver tag (usage — task tag version=v1.2.3)
|
||||||
|
preconditions:
|
||||||
|
- sh: '[[ "{{.version}}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]'
|
||||||
|
msg: "version must be semver, e.g. v1.2.3 or v1.2.3-rc.1"
|
||||||
|
- sh: "git diff --quiet && git diff --cached --quiet"
|
||||||
|
msg: "working tree must be clean before tagging"
|
||||||
|
cmds:
|
||||||
|
- git tag -a {{.version}} -m "Release {{.version}}"
|
||||||
|
- git push origin {{.version}}
|
||||||
|
|
||||||
|
push:
|
||||||
|
desc: Push current branch and tags to origin
|
||||||
|
vars:
|
||||||
|
BRANCH:
|
||||||
|
sh: git rev-parse --abbrev-ref HEAD
|
||||||
|
cmds:
|
||||||
|
- git push origin {{.BRANCH}} --tags
|
||||||
|
|||||||
3
brain/raw/tdd-pattern-test.md
Normal file
3
brain/raw/tdd-pattern-test.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# TDD Pattern
|
||||||
|
|
||||||
|
Always write the failing test first.
|
||||||
3
brain/wiki/concepts/tdd-pattern-test.md
Normal file
3
brain/wiki/concepts/tdd-pattern-test.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# TDD Pattern
|
||||||
|
|
||||||
|
Always write the failing test first.
|
||||||
59
cmd/bridge/main.go
Normal file
59
cmd/bridge/main.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// bridge is a stdio↔HTTP adapter that lets Claude Code connect to the
|
||||||
|
// supervisor MCP server via the stdio transport.
|
||||||
|
//
|
||||||
|
// Claude Code spawns this binary as a subprocess and communicates over
|
||||||
|
// stdin/stdout. Each newline-delimited JSON-RPC message from stdin is
|
||||||
|
// forwarded to the supervisor HTTP server and the response is written back.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// SUPERVISOR_URL=http://localhost:3200/mcp bridge
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
url := os.Getenv("SUPERVISOR_URL")
|
||||||
|
if url == "" {
|
||||||
|
url = "http://localhost:3200/mcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(bytes.TrimSpace(line)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(line))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "bridge: build request: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "bridge: request failed: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
io.Copy(os.Stdout, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
os.Stdout.Write([]byte("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "bridge: scanner: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,7 +10,12 @@ import (
|
|||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
"github.com/mathiasbq/supervisor/internal/mcp"
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
"github.com/mathiasbq/supervisor/internal/registry"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/brain"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/org"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/tdd"
|
"github.com/mathiasbq/supervisor/internal/skills/tdd"
|
||||||
|
"github.com/mathiasbq/supervisor/internal/tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -39,12 +45,22 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retroPrompt, err := os.ReadFile(cfg.ConfigDir + "/retrospective.md")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("read retrospective.md", "path", cfg.ConfigDir+"/retrospective.md", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
executor := iexec.New(iexec.Config{
|
executor := iexec.New(iexec.Config{
|
||||||
SystemPrompt: string(systemPrompt),
|
SystemPrompt: string(systemPrompt),
|
||||||
LiteLLMBaseURL: cfg.LiteLLMBaseURL,
|
LiteLLMBaseURL: cfg.LiteLLMBaseURL,
|
||||||
LiteLLMAPIKey: cfg.LiteLLMAPIKey,
|
LiteLLMAPIKey: cfg.LiteLLMAPIKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
tierFn := func(ctx context.Context) tier.Info {
|
||||||
|
return tier.Detect(ctx, "https://api.anthropic.com", cfg.LiteLLMBaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
reg.Register(tdd.New(tdd.Config{
|
reg.Register(tdd.New(tdd.Config{
|
||||||
SystemPrompt: string(systemPrompt),
|
SystemPrompt: string(systemPrompt),
|
||||||
@@ -52,6 +68,21 @@ func main() {
|
|||||||
DefaultModel: models.Resolve("tdd", ""),
|
DefaultModel: models.Resolve("tdd", ""),
|
||||||
ExecutorFn: executor.Run,
|
ExecutorFn: executor.Run,
|
||||||
}))
|
}))
|
||||||
|
reg.Register(brain.New(brain.Config{
|
||||||
|
IngestBaseURL: cfg.IngestBaseURL,
|
||||||
|
}))
|
||||||
|
reg.Register(org.New(org.Config{
|
||||||
|
TierFn: tierFn,
|
||||||
|
}))
|
||||||
|
reg.Register(sessionlog.New(sessionlog.Config{
|
||||||
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
}))
|
||||||
|
reg.Register(retrospective.New(retrospective.Config{
|
||||||
|
SkillPrompt: string(retroPrompt),
|
||||||
|
DefaultModel: models.Resolve("retrospective", ""),
|
||||||
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
ExecutorFn: executor.Run,
|
||||||
|
}))
|
||||||
|
|
||||||
srv := mcp.NewServer(reg)
|
srv := mcp.NewServer(reg)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ skills:
|
|||||||
tdd: ollama/qwen3-coder-30b-tuned
|
tdd: ollama/qwen3-coder-30b-tuned
|
||||||
review: ollama/devstral-tuned
|
review: ollama/devstral-tuned
|
||||||
debug: ollama/deepseek-r1-tuned
|
debug: ollama/deepseek-r1-tuned
|
||||||
|
retrospective: ollama/qwen3-coder-30b-tuned
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type queryRequest struct {
|
|||||||
type writeRequest struct {
|
type writeRequest struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Filename string `json:"filename,omitempty"`
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query handles POST /query — full-text search across the brain wiki.
|
// Query handles POST /query — full-text search across the brain wiki.
|
||||||
@@ -83,8 +85,22 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalContent := req.Content
|
||||||
|
if req.Type != "" || req.Domain != "" {
|
||||||
|
var fm strings.Builder
|
||||||
|
fm.WriteString("---\n")
|
||||||
|
if req.Type != "" {
|
||||||
|
fmt.Fprintf(&fm, "type: %s\n", req.Type)
|
||||||
|
}
|
||||||
|
if req.Domain != "" {
|
||||||
|
fmt.Fprintf(&fm, "domain: %s\n", req.Domain)
|
||||||
|
}
|
||||||
|
fm.WriteString("---\n")
|
||||||
|
finalContent = fm.String() + req.Content
|
||||||
|
}
|
||||||
|
|
||||||
dest := filepath.Join(rawDir, filepath.Base(filename))
|
dest := filepath.Join(rawDir, filepath.Base(filename))
|
||||||
if err := os.WriteFile(dest, []byte(req.Content), 0o644); err != nil {
|
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
||||||
h.logger.Error("write failed", "err", err)
|
h.logger.Error("write failed", "err", err)
|
||||||
http.Error(w, "write error", http.StatusInternalServerError)
|
http.Error(w, "write error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ func TestQuery_RequiresQuery(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"content": "Some learning.",
|
||||||
|
"filename": "typed-note.md",
|
||||||
|
"type": "concept",
|
||||||
|
"domain": "software",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.Write(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, "raw", "typed-note.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(content), "type: concept")
|
||||||
|
assert.Contains(t, string(content), "domain: software")
|
||||||
|
assert.Contains(t, string(content), "Some learning.")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ type Config struct {
|
|||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
||||||
ModelsFile string // SUPERVISOR_MODELS_FILE, default <ConfigDir>/../models.yaml
|
ModelsFile string // SUPERVISOR_MODELS_FILE, default <ConfigDir>/../models.yaml
|
||||||
|
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
|
||||||
|
SessionsDir string // SUPERVISOR_SESSIONS_DIR, default ./brain/sessions
|
||||||
|
BrainDir string // SUPERVISOR_BRAIN_DIR, default ./brain
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -18,6 +21,9 @@ func Load() (Config, error) {
|
|||||||
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
||||||
}
|
}
|
||||||
cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml")
|
cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml")
|
||||||
|
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
|
||||||
|
cfg.SessionsDir = envOr("SUPERVISOR_SESSIONS_DIR", "./brain/sessions")
|
||||||
|
cfg.BrainDir = envOr("SUPERVISOR_BRAIN_DIR", "./brain")
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,18 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
t.Setenv("LITELLM_BASE_URL", "")
|
t.Setenv("LITELLM_BASE_URL", "")
|
||||||
t.Setenv("LITELLM_API_KEY", "")
|
t.Setenv("LITELLM_API_KEY", "")
|
||||||
t.Setenv("SUPERVISOR_CONFIG_DIR", "")
|
t.Setenv("SUPERVISOR_CONFIG_DIR", "")
|
||||||
|
t.Setenv("INGEST_BASE_URL", "")
|
||||||
|
t.Setenv("SUPERVISOR_SESSIONS_DIR", "")
|
||||||
|
t.Setenv("SUPERVISOR_BRAIN_DIR", "")
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "3200", cfg.Port)
|
assert.Equal(t, "3200", cfg.Port)
|
||||||
assert.Equal(t, "http://iguana:4000", cfg.LiteLLMBaseURL)
|
assert.Equal(t, "http://iguana:4000", cfg.LiteLLMBaseURL)
|
||||||
assert.Equal(t, "./config/supervisor", cfg.ConfigDir)
|
assert.Equal(t, "./config/supervisor", cfg.ConfigDir)
|
||||||
|
assert.Equal(t, "http://localhost:3300", cfg.IngestBaseURL)
|
||||||
|
assert.Equal(t, "./brain/sessions", cfg.SessionsDir)
|
||||||
|
assert.Equal(t, "./brain", cfg.BrainDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadFromEnv(t *testing.T) {
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
|
|||||||
@@ -68,11 +68,10 @@ func (e *Executor) Run(ctx context.Context, req Request) (Result, error) {
|
|||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"--print",
|
"--print",
|
||||||
"--bare",
|
|
||||||
"--permission-mode", "bypassPermissions",
|
"--permission-mode", "bypassPermissions",
|
||||||
"--tools", tools,
|
"--tools", tools,
|
||||||
"--json-schema", Schema,
|
"--json-schema", Schema,
|
||||||
"--output-format", "text",
|
"--output-format", "json",
|
||||||
prompt,
|
prompt,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +88,21 @@ func (e *Executor) Run(ctx context.Context, req Request) (Result, error) {
|
|||||||
return Result{}, fmt.Errorf("claude exited with error: %w — stderr: %s", err, stderr.String())
|
return Result{}, fmt.Errorf("claude exited with error: %w — stderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var r Result
|
// --output-format json wraps the response in an envelope; structured output
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &r); err != nil {
|
// from --json-schema is in the "structured_output" field.
|
||||||
return Result{}, fmt.Errorf("parse result JSON: %w — raw output: %s", err, stdout.String())
|
var envelope struct {
|
||||||
|
StructuredOutput *Result `json:"structured_output"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
Result string `json:"result"` // fallback text result for error messages
|
||||||
}
|
}
|
||||||
if err := r.Validate(); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("parse envelope JSON: %w — raw: %s — stderr: %s", err, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if envelope.StructuredOutput == nil {
|
||||||
|
return Result{}, fmt.Errorf("no structured_output in response — result: %s — stderr: %s", envelope.Result, stderr.String())
|
||||||
|
}
|
||||||
|
if err := envelope.StructuredOutput.Validate(); err != nil {
|
||||||
return Result{}, fmt.Errorf("invalid result: %w", err)
|
return Result{}, fmt.Errorf("invalid result: %w", err)
|
||||||
}
|
}
|
||||||
return r, nil
|
return *envelope.StructuredOutput, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user