fix(exec): use --output-format json to get structured output from claude

--json-schema combined with --output-format text produces empty stdout.
The structured result is in the "structured_output" field of the json
envelope. Updated executor to unwrap the envelope.

Also removes --bare flag which disables OAuth keychain reads, causing
silent auth failure when ANTHROPIC_API_KEY is not set.

Adds goreman Procfile + stdio bridge (cmd/bridge) for Claude Code MCP
integration. Task start/stop replaced with goreman + port-kill.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathias Bergqvist
2026-04-18 06:04:10 +02:00
parent 98acf1c14e
commit 4bf5edb78e
4 changed files with 86 additions and 19 deletions

2
Procfile Normal file
View 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/

View File

@@ -66,6 +66,11 @@ tasks:
cmds:
- go build -o bin/supervisor ./cmd/supervisor
bridge:build:
desc: Build stdio↔HTTP bridge for Claude Code MCP integration
cmds:
- go build -o bin/supervisor-bridge ./cmd/bridge
supervisor:test:smoke:
desc: Smoke test supervisor via MCP (requires supervisor:dev running)
cmds:
@@ -75,22 +80,15 @@ tasks:
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
start:
desc: Start the full hyperguild (ingestion + supervisor) in a tmux session
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
cmds:
- |
tmux new-session -d -s hyperguild -x 220 -y 50 2>/dev/null || true
tmux rename-window -t hyperguild:0 'ingestion'
tmux send-keys -t hyperguild:ingestion "cd {{.ROOT_DIR}} && INGEST_BRAIN_DIR={{.ROOT_DIR}}/brain INGEST_PORT=3300 go run ./ingestion/cmd/server/" Enter
tmux new-window -t hyperguild -n 'supervisor'
tmux send-keys -t hyperguild:supervisor "cd {{.ROOT_DIR}} && SUPERVISOR_CONFIG_DIR={{.ROOT_DIR}}/config/supervisor SUPERVISOR_MODELS_FILE={{.ROOT_DIR}}/config/models.yaml SUPERVISOR_SESSIONS_DIR={{.ROOT_DIR}}/brain/sessions INGEST_BASE_URL=http://localhost:3300 go run ./cmd/supervisor/" Enter
tmux new-window -t hyperguild -n 'shell'
tmux select-window -t hyperguild:shell
tmux attach -t hyperguild
- goreman start
stop:
desc: Stop the hyperguild tmux session
desc: Stop all hyperguild processes (Ctrl-C in the goreman terminal, or kill by port)
cmds:
- tmux kill-session -t hyperguild 2>/dev/null || true
- lsof -ti:3300 | xargs kill -9 2>/dev/null || true
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
- echo "hyperguild stopped"
ingestion:build:

59
cmd/bridge/main.go Normal file
View 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)
}
}

View File

@@ -68,11 +68,10 @@ func (e *Executor) Run(ctx context.Context, req Request) (Result, error) {
args := []string{
"--print",
"--bare",
"--permission-mode", "bypassPermissions",
"--tools", tools,
"--json-schema", Schema,
"--output-format", "text",
"--output-format", "json",
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())
}
var r Result
if err := json.Unmarshal(stdout.Bytes(), &r); err != nil {
return Result{}, fmt.Errorf("parse result JSON: %w — raw output: %s", err, stdout.String())
// --output-format json wraps the response in an envelope; structured output
// from --json-schema is in the "structured_output" field.
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 r, nil
return *envelope.StructuredOutput, nil
}