fix: hyperguild brain write requires wing/hall, not legacy type/slug
PR #131 removed WriteNote's legacy brain/knowledge/ fallback branch that activated when wing/hall were both empty, but never updated the CLI that was the only caller relying on it. Since `hyperguild brain write` never sent wing/hall, every invocation of the CLI's most load-bearing brain command started hard-erroring "wing and hall are required" — a live, README-documented command broken on every call. Separately, writeRequest never had a `slug` field (pre-existing, unrelated to #131): the CLI's slug value was silently dropped server-side even before this PR, falling back to a timestamp filename. Fix: - `hyperguild brain write <wing> <hall> <slug>` replaces the old `<type> <slug>` shape. Positional args, mirroring the existing `brain query <topic>` pattern and matching writeHallNote's own wing/hall/slug parameter order server-side (handler.go's brain.NotePath(brainDir, wing, hall, slug)) — no new flags needed since wing/hall/slug are all required, not optional filters. - brainClient.Write now sends {wing, hall, filename, content}, matching writeRequest exactly (writeRequest already gained a Filename field in #131, so no server-side change was needed — only the CLI was sending the wrong shape). This also fixes the dead slug bug: slug now arrives server-side as filename instead of being silently dropped. - Added TestWrite_CLIPayloadRoundTrips in ingestion/internal/api, which POSTs the CLI's exact JSON envelope to the real Handler.Write (not a mock) and asserts a real file with real wing/hall frontmatter lands on disk. cmd/hyperguild and ingestion are separate Go modules (no go.work), so this can't be a single cross-module test — instead the wire contract is pinned from the server side, closing the gap where cmd/hyperguild/http_test.go's TestBrainClient_Write_Success used only a hand-rolled httptest mock and stayed green through the wing/hall regression. - Updated cmd/hyperguild/README.md and the 2026-05-03 CLI design spec's stale `<type> <slug>` examples. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,18 +55,21 @@ Flags:
|
|||||||
- `--limit N` — max results (default 5)
|
- `--limit N` — max results (default 5)
|
||||||
- `--json` — emit the raw response envelope
|
- `--json` — emit the raw response envelope
|
||||||
|
|
||||||
### `hyperguild brain write <type> <slug>`
|
### `hyperguild brain write <wing> <hall> <slug>`
|
||||||
|
|
||||||
Reads markdown from stdin, writes a knowledge entry.
|
Reads markdown from stdin, writes it to `brain/wiki/<wing>/<hall>/<slug>.md`
|
||||||
|
with `wing`/`hall`/`created_at` frontmatter injected (there is no legacy
|
||||||
|
`brain/knowledge/` fallback — ADR-0012 / #79, both wing and hall are
|
||||||
|
required).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cat <<EOF | hyperguild brain write knowledge example-lesson
|
$ cat <<EOF | hyperguild brain write homelab facts example-lesson
|
||||||
# Example lesson
|
# Example lesson
|
||||||
|
|
||||||
## Lesson
|
## Lesson
|
||||||
...
|
...
|
||||||
EOF
|
EOF
|
||||||
knowledge/example-lesson.md
|
wiki/homelab/facts/example-lesson.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### `hyperguild brain pass-rate <skill>`
|
### `hyperguild brain pass-rate <skill>`
|
||||||
|
|||||||
@@ -60,13 +60,14 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout,
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("parse flags: %w", err)
|
return fmt.Errorf("parse flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() < 2 {
|
if fs.NArg() < 3 {
|
||||||
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
|
return errors.New("wing, hall and slug required (e.g. brain write homelab facts my-slug)")
|
||||||
}
|
}
|
||||||
kind := fs.Arg(0)
|
wing := fs.Arg(0)
|
||||||
slug := fs.Arg(1)
|
hall := fs.Arg(1)
|
||||||
|
slug := fs.Arg(2)
|
||||||
|
|
||||||
res, err := newBrainClient().Write(ctx, kind, slug, stdin)
|
res, err := newBrainClient().Write(ctx, wing, hall, slug, stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
|||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(
|
err := runBrain(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"write", "knowledge", "test-slug"},
|
[]string{"write", "homelab", "facts", "test-slug"},
|
||||||
strings.NewReader("# Test\n\nSome body content.\n"),
|
strings.NewReader("# Test\n\nSome body content.\n"),
|
||||||
&out, &errBuf,
|
&out, &errBuf,
|
||||||
)
|
)
|
||||||
@@ -111,9 +111,9 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "homelab"}, strings.NewReader("x"), &out, &errBuf)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "type and slug required")
|
assert.Contains(t, err.Error(), "wing, hall and slug required")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunBrainWrite_BackendError(t *testing.T) {
|
func TestRunBrainWrite_BackendError(t *testing.T) {
|
||||||
@@ -127,7 +127,7 @@ func TestRunBrainWrite_BackendError(t *testing.T) {
|
|||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(
|
err := runBrain(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"write", "knowledge", "bad slug"},
|
[]string{"write", "homelab", "facts", "bad slug"},
|
||||||
strings.NewReader("body"),
|
strings.NewReader("body"),
|
||||||
&out, &errBuf,
|
&out, &errBuf,
|
||||||
)
|
)
|
||||||
@@ -150,7 +150,7 @@ func TestRunBrainWrite_EmptyStdin(t *testing.T) {
|
|||||||
t.Setenv("BRAIN_URL", srv.URL)
|
t.Setenv("BRAIN_URL", srv.URL)
|
||||||
|
|
||||||
var out, errBuf bytes.Buffer
|
var out, errBuf bytes.Buffer
|
||||||
err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf)
|
err := runBrain(context.Background(), []string{"write", "homelab", "facts", "empty"}, strings.NewReader(""), &out, &errBuf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,16 +84,17 @@ type WriteResult struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) {
|
func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, content io.Reader) (*WriteResult, error) {
|
||||||
body, err := io.ReadAll(content)
|
body, err := io.ReadAll(content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read content: %w", err)
|
return nil, fmt.Errorf("read content: %w", err)
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(struct {
|
payload, err := json.Marshal(struct {
|
||||||
Type string `json:"type"`
|
Wing string `json:"wing"`
|
||||||
Slug string `json:"slug"`
|
Hall string `json:"hall"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}{Type: kind, Slug: slug, Content: string(body)})
|
}{Wing: wing, Hall: hall, Filename: filename, Content: string(body)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,23 +65,25 @@ func TestBrainClient_Write_Success(t *testing.T) {
|
|||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
var got struct {
|
var got struct {
|
||||||
Type string `json:"type"`
|
Wing string `json:"wing"`
|
||||||
Slug string `json:"slug"`
|
Hall string `json:"hall"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
require.NoError(t, json.Unmarshal(body, &got))
|
require.NoError(t, json.Unmarshal(body, &got))
|
||||||
assert.Equal(t, "knowledge", got.Type)
|
assert.Equal(t, "homelab", got.Wing)
|
||||||
assert.Equal(t, "find-h", got.Slug)
|
assert.Equal(t, "facts", got.Hall)
|
||||||
|
assert.Equal(t, "find-h", got.Filename)
|
||||||
assert.Equal(t, "# body\n", got.Content)
|
assert.Equal(t, "# body\n", got.Content)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`))
|
_, _ = w.Write([]byte(`{"path":"wiki/homelab/facts/find-h.md"}`))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||||
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
|
res, err := c.Write(context.Background(), "homelab", "facts", "find-h", strings.NewReader("# body\n"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "knowledge/find-h.md", res.Path)
|
assert.Equal(t, "wiki/homelab/facts/find-h.md", res.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainClient_PassRate_Success(t *testing.T) {
|
func TestBrainClient_PassRate_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Three needs converge on a single small Go binary:
|
|||||||
|
|
||||||
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
|
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
|
||||||
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
|
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
|
||||||
- [ ] `hyperguild brain write <type> <slug>` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry.
|
- [ ] `hyperguild brain write <wing> <hall> <slug>` reads markdown content from stdin, posts to `/write` with wing/hall/filename, and creates `brain/wiki/<wing>/<hall>/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry. (Updated post-ADR-0012/#79: the legacy `<type> <slug>` shape and the `brain/knowledge/` fallback were removed; wing and hall are now required.)
|
||||||
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
|
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
|
||||||
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
|
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
|
||||||
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
|
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
|
||||||
@@ -50,7 +50,7 @@ Three needs converge on a single small Go binary:
|
|||||||
|
|
||||||
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
|
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
|
||||||
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
|
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
|
||||||
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
|
- `brain write <wing> <hall> <slug>` reads stdin, posts `POST /write` with JSON body `{wing, hall, filename, content}`, prints the resulting path on success. (Updated post-ADR-0012/#79: wing and hall are required, replacing the old `<type> <slug>` shape and the `brain/knowledge/` legacy fallback.)
|
||||||
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
|
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
|
||||||
|
|
||||||
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
||||||
|
|||||||
@@ -251,6 +251,50 @@ func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
|||||||
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWrite_CLIPayloadRoundTrips exercises the real Handler.Write (not a
|
||||||
|
// hand-rolled mock) over an actual HTTP round trip, POSTing the exact JSON
|
||||||
|
// envelope cmd/hyperguild's brainClient.Write sends: {wing, hall, filename,
|
||||||
|
// content}. cmd/hyperguild lives in a separate Go module from ingestion
|
||||||
|
// (no shared go.work), so a Go-level test can't import brainClient
|
||||||
|
// directly — this test instead pins the wire contract from the server
|
||||||
|
// side and proves a real `hyperguild brain write <wing> <hall> <slug>`
|
||||||
|
// invocation writes a real file with real frontmatter under wiki/<wing>/<hall>/.
|
||||||
|
// A prior version of this contract test on the client side (in
|
||||||
|
// cmd/hyperguild/http_test.go) used only an httptest.NewServer with a
|
||||||
|
// hand-rolled mock body, so it stayed green through a regression where the
|
||||||
|
// server required wing/hall but the client never sent them (hyperguild#79
|
||||||
|
// PR review).
|
||||||
|
func TestWrite_CLIPayloadRoundTrips(t *testing.T) {
|
||||||
|
dir, h := setup(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(h.Write))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
payload, err := json.Marshal(struct {
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Wing: "homelab", Hall: "facts", Filename: "cli-roundtrip", Content: "# CLI roundtrip\n\nReal write.\n"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := http.Post(srv.URL+"/write", "application/json", bytes.NewReader(payload)) //nolint:noctx
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var out struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||||
|
assert.Equal(t, "wiki/homelab/facts/cli-roundtrip.md", out.Path)
|
||||||
|
|
||||||
|
written, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "cli-roundtrip.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(written), "wing: homelab")
|
||||||
|
assert.Contains(t, string(written), "hall: facts")
|
||||||
|
assert.Contains(t, string(written), "Real write.")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /ingest
|
// POST /ingest
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user