Compare commits
6 Commits
7d5289ac54
...
823de23213
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823de23213 | ||
|
|
78d3939caa | ||
|
|
f2bc39b500 | ||
|
|
3625e1268d | ||
|
|
47df642836 | ||
|
|
235d70ad0b |
23
DECISIONS.md
23
DECISIONS.md
@@ -44,6 +44,29 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
|||||||
|
|
||||||
**Consequences**: More operational complexity than Chroma, but isolation is non-negotiable for client work.
|
**Consequences**: More operational complexity than Chroma, but isolation is non-negotiable for client work.
|
||||||
|
|
||||||
|
## 2026-04-22 — Hyperguild scope reset: drop parametric learning, simplify brain
|
||||||
|
|
||||||
|
**Context**: After shipping Phases 1–4 (MCP server, 6 skills, model orchestration, session logging, CD pipeline), we critically reviewed what was theater vs genuinely useful.
|
||||||
|
|
||||||
|
**Decisions**:
|
||||||
|
|
||||||
|
1. **Drop the parametric learning pipeline.** SFT/DPO/RL extraction, `brain/training-data/` directory structure, Axolotl/LLaMA-Factory fine-tuning loop — all cut. The loop requires thousands of high-quality examples to move the needle, which a solo consultant won't generate. Better base models ship faster than any fine-tuning effort could keep up with. This is a research project, not a productivity tool.
|
||||||
|
|
||||||
|
2. **Simplify the brain to plain markdown.** `brain/knowledge/` replaces `brain/wiki/ + brain/raw/ + brain/training-data/`. The trainer and retrospective workers write markdown entries. `brain_query` searches markdown. No ingestion pipeline, no tagging for significance review, no structured JSONL formats.
|
||||||
|
|
||||||
|
3. **Measure the escalation chain before assuming it's useful.** Local model (phi4) only belongs in a skill's chain if it passes Claude verification at a meaningful rate. Where it fails >70% of the time, it adds cost not value. Per-skill hit rate logging is the prerequisite to honest chain configuration.
|
||||||
|
|
||||||
|
4. **Keep what's real**: MCP tool surface, session logging with attempt records, tier detection, CD pipeline, bridge to Claude Code.
|
||||||
|
|
||||||
|
**What to build next** (in priority order):
|
||||||
|
- `brain_query` injection into skill handlers before spawning workers — this makes the declarative brain actually function
|
||||||
|
- `protocols.md` — behavioral contract injected into every worker prompt
|
||||||
|
- Per-skill pass rate logging and chain tuning
|
||||||
|
|
||||||
|
**Consequences**: Simpler system with a shorter feedback loop. The brain becomes real only when skill handlers query it. Training data ambitions deferred indefinitely — revisit if local model capabilities improve enough that fine-tuning becomes worthwhile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-08 — Mistral Vibe gets its own adapter
|
## 2026-04-08 — Mistral Vibe gets its own adapter
|
||||||
|
|
||||||
**Context**: Vibe doesn't read `AGENTS.md` — it uses `~/.vibe/prompts/` and `~/.vibe/agents/` with TOML config.
|
**Context**: Vibe doesn't read `AGENTS.md` — it uses `~/.vibe/prompts/` and `~/.vibe/agents/` with TOML config.
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protocolsPrompt, err := os.ReadFile(cfg.ConfigDir + "/protocols.md")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("read protocols.md", "path", cfg.ConfigDir+"/protocols.md", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prependProtocols prepends the shared protocols to a skill discipline file.
|
||||||
|
prependProtocols := func(skillPrompt []byte) string {
|
||||||
|
return string(protocolsPrompt) + "\n---\n\n" + string(skillPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
tddPrompt, err := os.ReadFile(cfg.ConfigDir + "/tdd.md")
|
tddPrompt, err := os.ReadFile(cfg.ConfigDir + "/tdd.md")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("read tdd.md", "path", cfg.ConfigDir+"/tdd.md", "err", err)
|
logger.Error("read tdd.md", "path", cfg.ConfigDir+"/tdd.md", "err", err)
|
||||||
@@ -103,6 +114,18 @@ func main() {
|
|||||||
orch := iexec.NewOrchestrator(chain, litellmExec.Run, claudeExec.Run, verifier, models.LlamaSwapURL(), &attempts)
|
orch := iexec.NewOrchestrator(chain, litellmExec.Run, claudeExec.Run, verifier, models.LlamaSwapURL(), &attempts)
|
||||||
result, err := orch.Run(ctx, req)
|
result, err := orch.Run(ctx, req)
|
||||||
result.Attempts = attempts // attach orchestration metadata before returning
|
result.Attempts = attempts // attach orchestration metadata before returning
|
||||||
|
// Log per-attempt verdicts so pass rates are visible in pod logs.
|
||||||
|
for i, a := range attempts {
|
||||||
|
logger.Info("chain attempt",
|
||||||
|
"skill", skill,
|
||||||
|
"attempt", i+1,
|
||||||
|
"model", a.Model,
|
||||||
|
"tier", a.Tier,
|
||||||
|
"verdict", a.Verdict,
|
||||||
|
"duration_ms", a.DurationMs,
|
||||||
|
"warm", a.WarmStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,11 +136,12 @@ func main() {
|
|||||||
|
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
reg.Register(tdd.New(tdd.Config{
|
reg.Register(tdd.New(tdd.Config{
|
||||||
SystemPrompt: string(systemPrompt),
|
SystemPrompt: string(systemPrompt),
|
||||||
SkillPrompt: string(tddPrompt),
|
SkillPrompt: prependProtocols(tddPrompt),
|
||||||
DefaultModel: models.ChainFor("tdd", "")[0],
|
DefaultModel: models.ChainFor("tdd", "")[0],
|
||||||
ExecutorFn: buildOrch("tdd"),
|
ExecutorFn: buildOrch("tdd"),
|
||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
IngestBaseURL: cfg.IngestBaseURL,
|
||||||
}))
|
}))
|
||||||
reg.Register(brain.New(brain.Config{
|
reg.Register(brain.New(brain.Config{
|
||||||
IngestBaseURL: cfg.IngestBaseURL,
|
IngestBaseURL: cfg.IngestBaseURL,
|
||||||
@@ -129,32 +153,35 @@ func main() {
|
|||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
}))
|
}))
|
||||||
reg.Register(retrospective.New(retrospective.Config{
|
reg.Register(retrospective.New(retrospective.Config{
|
||||||
SkillPrompt: string(retroPrompt),
|
SkillPrompt: prependProtocols(retroPrompt),
|
||||||
DefaultModel: models.ChainFor("retrospective", "")[0],
|
DefaultModel: models.ChainFor("retrospective", "")[0],
|
||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
ExecutorFn: buildOrch("retrospective"),
|
ExecutorFn: buildOrch("retrospective"),
|
||||||
}))
|
}))
|
||||||
reg.Register(review.New(review.Config{
|
reg.Register(review.New(review.Config{
|
||||||
SkillPrompt: string(reviewPrompt),
|
SkillPrompt: prependProtocols(reviewPrompt),
|
||||||
DefaultModel: models.ChainFor("review", "")[0],
|
DefaultModel: models.ChainFor("review", "")[0],
|
||||||
ExecutorFn: buildOrch("review"),
|
ExecutorFn: buildOrch("review"),
|
||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
IngestBaseURL: cfg.IngestBaseURL,
|
||||||
}))
|
}))
|
||||||
reg.Register(skilldebug.New(skilldebug.Config{
|
reg.Register(skilldebug.New(skilldebug.Config{
|
||||||
SkillPrompt: string(debugPrompt),
|
SkillPrompt: prependProtocols(debugPrompt),
|
||||||
DefaultModel: models.ChainFor("debug", "")[0],
|
DefaultModel: models.ChainFor("debug", "")[0],
|
||||||
ExecutorFn: buildOrch("debug"),
|
ExecutorFn: buildOrch("debug"),
|
||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
IngestBaseURL: cfg.IngestBaseURL,
|
||||||
}))
|
}))
|
||||||
reg.Register(spec.New(spec.Config{
|
reg.Register(spec.New(spec.Config{
|
||||||
SkillPrompt: string(specPrompt),
|
SkillPrompt: prependProtocols(specPrompt),
|
||||||
DefaultModel: models.ChainFor("spec", "")[0],
|
DefaultModel: models.ChainFor("spec", "")[0],
|
||||||
ExecutorFn: buildOrch("spec"),
|
ExecutorFn: buildOrch("spec"),
|
||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
IngestBaseURL: cfg.IngestBaseURL,
|
||||||
}))
|
}))
|
||||||
reg.Register(trainer.New(trainer.Config{
|
reg.Register(trainer.New(trainer.Config{
|
||||||
ReaderPrompt: string(trainerReaderPrompt),
|
ReaderPrompt: prependProtocols(trainerReaderPrompt),
|
||||||
WriterPrompt: string(trainerWriterPrompt),
|
WriterPrompt: prependProtocols(trainerWriterPrompt),
|
||||||
DefaultModel: models.ChainFor("trainer", "")[0],
|
DefaultModel: models.ChainFor("trainer", "")[0],
|
||||||
ExecutorFn: buildOrch("trainer"),
|
ExecutorFn: buildOrch("trainer"),
|
||||||
SessionsDir: cfg.SessionsDir,
|
SessionsDir: cfg.SessionsDir,
|
||||||
|
|||||||
@@ -1,27 +1,55 @@
|
|||||||
# The Hyperguild Way
|
# The Hyperguild Way — Worker Protocols
|
||||||
|
|
||||||
These protocols are injected into every worker invocation. They define how you behave as a member of the hyperguild.
|
Injected into every worker invocation alongside the skill discipline file.
|
||||||
|
Defines the behavioral contract all workers must follow regardless of skill or model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Output contract
|
## Output contract
|
||||||
|
|
||||||
Every response is raw JSON matching the response schema. No preamble, no prose, no markdown. Malformed output is treated as a failed invocation.
|
Every response is raw JSON matching the skill's output schema.
|
||||||
|
- No preamble, no explanation, no prose before or after the JSON
|
||||||
|
- No markdown code fences around the JSON
|
||||||
|
- Malformed output is treated as a failed invocation
|
||||||
|
- If uncertain, return valid JSON anyway — put uncertainty in `message`
|
||||||
|
|
||||||
## Quality gate
|
## Quality gate
|
||||||
|
|
||||||
`verified: true` only when a subprocess exit code confirms the outcome. Never self-assess. "I think the tests pass" is not verified.
|
`verified: true` means the artifact was independently confirmed correct:
|
||||||
|
- For code: the test runner exited 0
|
||||||
|
- For files: the file exists at the path you wrote
|
||||||
|
- Never set `verified: true` based on self-assessment — only on external confirmation
|
||||||
|
- "I think the tests pass" is not verified. Run them.
|
||||||
|
|
||||||
## Escalation
|
## Escalation
|
||||||
|
|
||||||
If stuck after 3 attempts, return `status: error` with a clear `message` explaining why. Do not retry silently. Do not fabricate a passing result.
|
If stuck after a genuine attempt:
|
||||||
|
- Return `status: error` with a clear reason in `message`
|
||||||
|
- Do not retry silently or produce low-confidence output labelled as a pass
|
||||||
|
- Do not hallucinate file paths, test results, or exit codes
|
||||||
|
- Maximum 3 attempts per phase — if output is identical across two consecutive attempts, stop immediately
|
||||||
|
|
||||||
## Working offline
|
## Context usage
|
||||||
|
|
||||||
If brain context is absent from your prompt, proceed using your discipline file only. Note the gap in your `message` field: "no brain context available".
|
You may receive a `## Relevant knowledge` block and/or a `## Session history` block before your task.
|
||||||
|
- **Relevant knowledge**: patterns, decisions, and conventions from past sessions. Let them inform your approach — do not contradict them without reason.
|
||||||
|
- **Session history**: what has already happened in this session. Build on it, do not repeat it.
|
||||||
|
- If either block is absent, proceed with the skill discipline file only. Note the absence in `message` if it materially affects quality.
|
||||||
|
|
||||||
## Handoff format
|
## Handoff discipline
|
||||||
|
|
||||||
Structure your output so the next worker in a chain can consume it without transformation. Use the standard result schema. Do not add extra fields.
|
Structure output so the next worker in a chain can consume it without transformation:
|
||||||
|
- `file_path`: absolute path to the primary artifact you produced
|
||||||
|
- `runner_output`: verbatim stdout+stderr from the last command you ran (truncate to 2000 chars if longer)
|
||||||
|
- `message`: one sentence — what you did and whether it worked
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
You have access to `Bash`, `Read`, and `Write` only.
|
||||||
|
- Do not attempt to call MCP tools or make HTTP requests
|
||||||
|
- Do not modify files outside `project_root` without explicit instruction
|
||||||
|
- If the task requires capabilities you do not have, return `status: error`
|
||||||
|
|
||||||
## Session logging
|
## Session logging
|
||||||
|
|
||||||
The Go skill handler records your invocation in the session log automatically. You do not need to do this yourself.
|
The Go skill handler records your invocation automatically. You do not need to log anything yourself.
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
|||||||
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
||||||
}
|
}
|
||||||
|
|
||||||
rawDir := filepath.Join(h.brainDir, "raw")
|
rawDir := filepath.Join(h.brainDir, "knowledge")
|
||||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||||
http.Error(w, "failed to create raw dir", http.StatusInternalServerError)
|
http.Error(w, "failed to create raw dir", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ import (
|
|||||||
func setup(t *testing.T) (string, *api.Handler) {
|
func setup(t *testing.T) (string, *api.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
filepath.Join(dir, "wiki", "concepts", "tdd.md"),
|
filepath.Join(dir, "knowledge", "tdd.md"),
|
||||||
[]byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"),
|
[]byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -46,7 +45,7 @@ func TestQuery_ReturnsResults(t *testing.T) {
|
|||||||
assert.NotEmpty(t, results)
|
assert.NotEmpty(t, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite_CreatesRawFile(t *testing.T) {
|
func TestWrite_CreatesKnowledgeFile(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"content": "# Test note\n\nSome content.",
|
"content": "# Test note\n\nSome content.",
|
||||||
@@ -62,8 +61,7 @@ func TestWrite_CreatesRawFile(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
assert.NotEmpty(t, resp["path"])
|
assert.NotEmpty(t, resp["path"])
|
||||||
|
|
||||||
written := filepath.Join(dir, "raw", "test-note.md")
|
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "test-note.md"))
|
||||||
content, err := os.ReadFile(written)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "Some content.")
|
assert.Contains(t, string(content), "Some content.")
|
||||||
}
|
}
|
||||||
@@ -93,7 +91,7 @@ func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
|||||||
h.Write(rec, req)
|
h.Write(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
content, err := os.ReadFile(filepath.Join(dir, "raw", "typed-note.md"))
|
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "typed-note.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), "type: concept")
|
assert.Contains(t, string(content), "type: concept")
|
||||||
assert.Contains(t, string(content), "domain: software")
|
assert.Contains(t, string(content), "domain: software")
|
||||||
@@ -109,7 +107,8 @@ func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
|||||||
h.Write(rec, req)
|
h.Write(rec, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
entries, _ := os.ReadDir(filepath.Join(dir, "raw"))
|
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
|
||||||
assert.Len(t, entries, 1)
|
// +1 because setup already wrote tdd.md
|
||||||
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
assert.Len(t, entries, 2)
|
||||||
|
assert.True(t, strings.HasSuffix(entries[1].Name(), ".md"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func Query(brainDir, query string, limit int) ([]Result, error) {
|
|||||||
|
|
||||||
var results []Result
|
var results []Result
|
||||||
|
|
||||||
err := filepath.WalkDir(filepath.Join(brainDir, "wiki"), func(path string, d os.DirEntry, err error) error {
|
err := filepath.WalkDir(filepath.Join(brainDir, "knowledge"), func(path string, d os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("search: skipping path", "path", path, "err", err)
|
slog.Warn("search: skipping path", "path", path, "err", err)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -14,17 +14,15 @@ import (
|
|||||||
|
|
||||||
func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
|
|
||||||
// Write a concept page mentioning "retry"
|
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
filepath.Join(dir, "wiki", "concepts", "retry-logic.md"),
|
filepath.Join(dir, "knowledge", "retry-logic.md"),
|
||||||
[]byte("---\ntitle: Retry Logic\ndomain: software\n---\n\nRetry logic handles transient failures by re-attempting operations.\n"),
|
[]byte("---\ntitle: Retry Logic\ndomain: software\n---\n\nRetry logic handles transient failures by re-attempting operations.\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
// Write an unrelated page
|
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
filepath.Join(dir, "wiki", "concepts", "database.md"),
|
filepath.Join(dir, "knowledge", "database.md"),
|
||||||
[]byte("---\ntitle: Database\ndomain: software\n---\n\nA database stores structured data.\n"),
|
[]byte("---\ntitle: Database\ndomain: software\n---\n\nA database stores structured data.\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -32,7 +30,7 @@ func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
|||||||
results, err := search.Query(dir, "retry transient", 5)
|
results, err := search.Query(dir, "retry transient", 5)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, results, 1)
|
require.Len(t, results, 1)
|
||||||
assert.Equal(t, "wiki/concepts/retry-logic.md", results[0].Path)
|
assert.Equal(t, "knowledge/retry-logic.md", results[0].Path)
|
||||||
assert.Equal(t, "Retry Logic", results[0].Title)
|
assert.Equal(t, "Retry Logic", results[0].Title)
|
||||||
assert.Greater(t, results[0].Score, 0)
|
assert.Greater(t, results[0].Score, 0)
|
||||||
assert.Contains(t, results[0].Excerpt, "Retry")
|
assert.Contains(t, results[0].Excerpt, "Retry")
|
||||||
@@ -40,10 +38,10 @@ func TestSearch_ReturnsMatchingPages(t *testing.T) {
|
|||||||
|
|
||||||
func TestSearch_RespectsLimit(t *testing.T) {
|
func TestSearch_RespectsLimit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
filepath.Join(dir, "wiki", "concepts", fmt.Sprintf("page-%d.md", i)),
|
filepath.Join(dir, "knowledge", fmt.Sprintf("page-%d.md", i)),
|
||||||
[]byte(fmt.Sprintf("---\ntitle: Page %d\n---\n\nThis page mentions retry.\n", i)),
|
[]byte(fmt.Sprintf("---\ntitle: Page %d\n---\n\nThis page mentions retry.\n", i)),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
|
|||||||
76
internal/brain/client.go
Normal file
76
internal/brain/client.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// internal/brain/client.go
|
||||||
|
// Package brain provides a lightweight client for querying the ingestion server.
|
||||||
|
// Skill handlers call Query before spawning workers to inject relevant knowledge
|
||||||
|
// from the brain into the task prompt. Errors are suppressed — the brain is
|
||||||
|
// optional context; its absence must never block a skill invocation.
|
||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type queryResult struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query calls the ingestion server and returns relevant knowledge as a
|
||||||
|
// formatted string ready to prepend to a worker task prompt.
|
||||||
|
// Returns empty string (no error) when baseURL or query is empty,
|
||||||
|
// when the brain is unreachable, or when no results are found.
|
||||||
|
func Query(ctx context.Context, baseURL, query string, limit int) (string, error) {
|
||||||
|
if baseURL == "" || strings.TrimSpace(query) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"query": query, "limit": limit})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/query", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("brain: build request failed", "err", err)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("brain: ingestion server unreachable", "err", err)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
slog.Warn("brain: ingestion server returned non-OK", "status", resp.StatusCode)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out, _ := io.ReadAll(resp.Body)
|
||||||
|
var result struct {
|
||||||
|
Results []queryResult `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &result); err != nil || len(result.Results) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("## Relevant knowledge\n\n")
|
||||||
|
for _, r := range result.Results {
|
||||||
|
title := r.Title
|
||||||
|
if title == "" {
|
||||||
|
title = r.Path
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "### %s\n%s\n\n", title, r.Excerpt)
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
67
internal/brain/client_test.go
Normal file
67
internal/brain/client_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQueryEmptyBaseURL(t *testing.T) {
|
||||||
|
result, err := brain.Query(context.Background(), "", "tdd patterns", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryEmptyQuery(t *testing.T) {
|
||||||
|
result, err := brain.Query(context.Background(), "http://localhost:9999", "", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryFormatsResults(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "/query", r.URL.Path)
|
||||||
|
var req map[string]any
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||||
|
assert.Equal(t, "tdd patterns", req["query"])
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
|
||||||
|
"results": []map[string]any{
|
||||||
|
{"path": "knowledge/tdd.md", "title": "TDD Guide", "excerpt": "Always write tests first.", "score": 5},
|
||||||
|
{"path": "knowledge/go.md", "title": "Go Conventions", "excerpt": "Use table-driven tests.", "score": 3},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
result, err := brain.Query(context.Background(), srv.URL, "tdd patterns", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, result, "## Relevant knowledge")
|
||||||
|
assert.Contains(t, result, "TDD Guide")
|
||||||
|
assert.Contains(t, result, "Always write tests first.")
|
||||||
|
assert.Contains(t, result, "Go Conventions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryEmptyResults(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"results": []any{}}) //nolint:errcheck
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
result, err := brain.Query(context.Background(), srv.URL, "obscure query", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryUnavailableServerReturnsEmpty(t *testing.T) {
|
||||||
|
// Brain unavailable — should degrade gracefully, no error
|
||||||
|
result, err := brain.Query(context.Background(), "http://127.0.0.1:19999", "query", 3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/brain"
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
"github.com/mathiasbq/supervisor/internal/session"
|
||||||
)
|
)
|
||||||
@@ -40,11 +41,16 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
|
|||||||
model = s.cfg.DefaultModel
|
model = s.cfg.DefaultModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, a.Error+" "+a.Context, 3)
|
||||||
|
|
||||||
task := fmt.Sprintf(
|
task := fmt.Sprintf(
|
||||||
"phase: debug\nproject_root: %s\nerror: %s\ncontext: %s\nmodel: %s",
|
"phase: debug\nproject_root: %s\nerror: %s\ncontext: %s\nmodel: %s",
|
||||||
a.ProjectRoot, a.Error, a.Context, model,
|
a.ProjectRoot, a.Error, a.Context, model,
|
||||||
)
|
)
|
||||||
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "debug", task)
|
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "debug", task)
|
||||||
|
if brainCtx != "" {
|
||||||
|
task = brainCtx + "\n---\n\n" + task
|
||||||
|
}
|
||||||
|
|
||||||
if s.cfg.ExecutorFn == nil {
|
if s.cfg.ExecutorFn == nil {
|
||||||
return nil, fmt.Errorf("no executor configured")
|
return nil, fmt.Errorf("no executor configured")
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, erro
|
|||||||
|
|
||||||
// Config holds dependencies for the debug skill.
|
// Config holds dependencies for the debug skill.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SkillPrompt string
|
SkillPrompt string
|
||||||
DefaultModel string
|
DefaultModel string
|
||||||
ExecutorFn ExecutorFn
|
ExecutorFn ExecutorFn
|
||||||
SessionsDir string
|
SessionsDir string
|
||||||
|
IngestBaseURL string // optional: base URL of ingestion server for brain context
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill implements the debug MCP tool.
|
// Skill implements the debug MCP tool.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/brain"
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
"github.com/mathiasbq/supervisor/internal/session"
|
||||||
)
|
)
|
||||||
@@ -41,11 +42,16 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
|
|||||||
model = s.cfg.DefaultModel
|
model = s.cfg.DefaultModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, strings.Join(a.Files, " ")+" "+a.Context, 3)
|
||||||
|
|
||||||
task := fmt.Sprintf(
|
task := fmt.Sprintf(
|
||||||
"phase: review\nproject_root: %s\nfiles: %s\ncontext: %s\nmodel: %s",
|
"phase: review\nproject_root: %s\nfiles: %s\ncontext: %s\nmodel: %s",
|
||||||
a.ProjectRoot, strings.Join(a.Files, ", "), a.Context, model,
|
a.ProjectRoot, strings.Join(a.Files, ", "), a.Context, model,
|
||||||
)
|
)
|
||||||
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "review", task)
|
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "review", task)
|
||||||
|
if brainCtx != "" {
|
||||||
|
task = brainCtx + "\n---\n\n" + task
|
||||||
|
}
|
||||||
|
|
||||||
if s.cfg.ExecutorFn == nil {
|
if s.cfg.ExecutorFn == nil {
|
||||||
return nil, fmt.Errorf("no executor configured")
|
return nil, fmt.Errorf("no executor configured")
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, erro
|
|||||||
|
|
||||||
// Config holds dependencies for the review skill.
|
// Config holds dependencies for the review skill.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SkillPrompt string
|
SkillPrompt string
|
||||||
DefaultModel string
|
DefaultModel string
|
||||||
ExecutorFn ExecutorFn
|
ExecutorFn ExecutorFn
|
||||||
SessionsDir string
|
SessionsDir string
|
||||||
|
IngestBaseURL string // optional: base URL of ingestion server for brain context
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill implements the review MCP tool.
|
// Skill implements the review MCP tool.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/brain"
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
"github.com/mathiasbq/supervisor/internal/session"
|
||||||
)
|
)
|
||||||
@@ -45,11 +46,16 @@ func (s *Skill) Handle(ctx context.Context, tool string, args json.RawMessage) (
|
|||||||
model = s.cfg.DefaultModel
|
model = s.cfg.DefaultModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, a.Requirements+" "+a.Context, 3)
|
||||||
|
|
||||||
task := fmt.Sprintf(
|
task := fmt.Sprintf(
|
||||||
"phase: spec\nproject_root: %s\nrequirements: %s\noutput_path: %s\ncontext: %s\nmodel: %s",
|
"phase: spec\nproject_root: %s\nrequirements: %s\noutput_path: %s\ncontext: %s\nmodel: %s",
|
||||||
a.ProjectRoot, a.Requirements, outputPath, a.Context, model,
|
a.ProjectRoot, a.Requirements, outputPath, a.Context, model,
|
||||||
)
|
)
|
||||||
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "spec", task)
|
task = session.PrependHistory(s.cfg.SessionsDir, a.SessionID, "spec", task)
|
||||||
|
if brainCtx != "" {
|
||||||
|
task = brainCtx + "\n---\n\n" + task
|
||||||
|
}
|
||||||
|
|
||||||
if s.cfg.ExecutorFn == nil {
|
if s.cfg.ExecutorFn == nil {
|
||||||
return nil, fmt.Errorf("no executor configured")
|
return nil, fmt.Errorf("no executor configured")
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, erro
|
|||||||
|
|
||||||
// Config holds dependencies for the spec skill.
|
// Config holds dependencies for the spec skill.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SkillPrompt string
|
SkillPrompt string
|
||||||
DefaultModel string
|
DefaultModel string
|
||||||
ExecutorFn ExecutorFn
|
ExecutorFn ExecutorFn
|
||||||
SessionsDir string
|
SessionsDir string
|
||||||
|
IngestBaseURL string // optional: base URL of ingestion server for brain context
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill implements the spec MCP tool.
|
// Skill implements the spec MCP tool.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/supervisor/internal/brain"
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||||
"github.com/mathiasbq/supervisor/internal/session"
|
"github.com/mathiasbq/supervisor/internal/session"
|
||||||
)
|
)
|
||||||
@@ -41,10 +42,15 @@ func (s *Skill) handleRed(ctx context.Context, raw json.RawMessage) (json.RawMes
|
|||||||
if args.Spec == "" {
|
if args.Spec == "" {
|
||||||
return nil, fmt.Errorf("spec is required")
|
return nil, fmt.Errorf("spec is required")
|
||||||
}
|
}
|
||||||
|
brainCtx, _ := brain.Query(ctx, s.cfg.IngestBaseURL, args.Spec, 3)
|
||||||
|
|
||||||
task := fmt.Sprintf(
|
task := fmt.Sprintf(
|
||||||
"phase: red\nproject_root: %s\nspec: %s\nmodel: %s\ntest_cmd: %s",
|
"phase: red\nproject_root: %s\nspec: %s\nmodel: %s\ntest_cmd: %s",
|
||||||
args.ProjectRoot, args.Spec, s.resolveModel(args.Model), args.TestCmd,
|
args.ProjectRoot, args.Spec, s.resolveModel(args.Model), args.TestCmd,
|
||||||
)
|
)
|
||||||
|
if brainCtx != "" {
|
||||||
|
task = brainCtx + "\n---\n\n" + task
|
||||||
|
}
|
||||||
return s.execute(ctx, task)
|
return s.execute(ctx, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import (
|
|||||||
type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, error)
|
type ExecutorFn func(ctx context.Context, req iexec.Request) (iexec.Result, error)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
SkillPrompt string
|
SkillPrompt string
|
||||||
ExecutorFn ExecutorFn // nil = no executor (tests that don't reach execute())
|
ExecutorFn ExecutorFn // nil = no executor (tests that don't reach execute())
|
||||||
DefaultModel string
|
DefaultModel string
|
||||||
SessionsDir string // optional: path to brain/sessions/ for history injection
|
SessionsDir string // optional: path to brain/sessions/ for history injection
|
||||||
|
IngestBaseURL string // optional: base URL of ingestion server for brain context
|
||||||
}
|
}
|
||||||
|
|
||||||
type Skill struct {
|
type Skill struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user