From 95a69fc2c145b054688e23e12790d6d3e7582559 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 22 Jun 2026 08:25:00 +0200 Subject: [PATCH 1/3] feat(brain): add UpdateNote/ReadNote supersede primitives + frontmatter editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the api-layer half of #45. UpdateNote supersedes a note in place (whole-note body replace, frontmatter re-stamp: updated_at, supersedes=prior content hash, supersede_reason), preserving created_at, wing, hall, and any custom fields. Never creates — a missing target is an error so callers fall back to brain_write. ReadNote is the read-after- write primitive (frontmatter + body + content_hash). ContentHash is the sha256 handle that round-trips write/update → get. Frontmatter is edited via a line-preserving ordered editor rather than a yaml.v3 round-trip, which would reorder keys and strip comments — the brain writes flat key:value frontmatter by hand. Embeddings are not refreshed here: the rewritten file's mtime advances, which the out-of-band vectorstore.Sync ticker uses to re-embed it — the same mechanism brain_write relies on. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/api/frontmatter.go | 97 +++++++++++++++ ingestion/internal/api/frontmatter_test.go | 61 ++++++++++ ingestion/internal/api/update.go | 131 +++++++++++++++++++++ ingestion/internal/api/update_test.go | 130 ++++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 ingestion/internal/api/frontmatter.go create mode 100644 ingestion/internal/api/frontmatter_test.go create mode 100644 ingestion/internal/api/update.go create mode 100644 ingestion/internal/api/update_test.go diff --git a/ingestion/internal/api/frontmatter.go b/ingestion/internal/api/frontmatter.go new file mode 100644 index 0000000..6569f15 --- /dev/null +++ b/ingestion/internal/api/frontmatter.go @@ -0,0 +1,97 @@ +package api + +import "strings" + +// frontmatter is an ordered, line-preserving view of a note's YAML +// frontmatter block. It deliberately avoids a full YAML round-trip: the +// brain writes flat `key: value` frontmatter by hand, and a yaml.v3 +// re-marshal would reorder keys and strip comments. Preserving the +// original lines verbatim keeps brain_update a surgical edit — only the +// keys it manages (updated_at, supersedes, supersede_reason) change. +type frontmatter struct { + lines []fmLine +} + +// fmLine is one frontmatter line. For `key: value` lines, key and value +// are populated; for blank lines, comments, or anything that isn't a +// simple scalar pair, key is empty and raw holds the line verbatim. +type fmLine struct { + key string + value string + raw string +} + +// parseFrontmatter splits src into its frontmatter block and body. A +// frontmatter block is recognised only when the file opens with a `---` +// fence and a closing `---` fence follows. Otherwise the whole input is +// the body and the returned frontmatter is empty. +func parseFrontmatter(src string) (frontmatter, string) { + var fm frontmatter + if !strings.HasPrefix(src, "---\n") { + return fm, src + } + rest := src[len("---\n"):] + end := strings.Index(rest, "\n---\n") + if end < 0 { + // Opening fence with no closing fence — treat as bodyless content. + return fm, src + } + block := rest[:end] + body := rest[end+len("\n---\n"):] + + for _, line := range strings.Split(block, "\n") { + key, val, ok := strings.Cut(line, ":") + key = strings.TrimSpace(key) + if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { + fm.lines = append(fm.lines, fmLine{raw: line}) + continue + } + fm.lines = append(fm.lines, fmLine{key: key, value: strings.TrimSpace(val)}) + } + return fm, body +} + +// get returns the value for key, or "" if absent. +func (f *frontmatter) get(key string) string { + for _, l := range f.lines { + if l.key == key { + return l.value + } + } + return "" +} + +// set overrides the value for an existing key in place, or appends a new +// `key: value` line when the key is absent. +func (f *frontmatter) set(key, value string) { + for i := range f.lines { + if f.lines[i].key == key { + f.lines[i].value = value + return + } + } + f.lines = append(f.lines, fmLine{key: key, value: value}) +} + +// render serialises the frontmatter back into a `---`-fenced block. An +// empty frontmatter renders to the empty string so bodies without a +// header stay header-less. +func (f *frontmatter) render() string { + if len(f.lines) == 0 { + return "" + } + var b strings.Builder + b.WriteString("---\n") + for _, l := range f.lines { + if l.key == "" { + b.WriteString(l.raw) + } else { + b.WriteString(l.key) + b.WriteString(": ") + b.WriteString(l.value) + } + b.WriteByte('\n') + } + b.WriteString("---\n") + return b.String() +} diff --git a/ingestion/internal/api/frontmatter_test.go b/ingestion/internal/api/frontmatter_test.go new file mode 100644 index 0000000..6bcb2e3 --- /dev/null +++ b/ingestion/internal/api/frontmatter_test.go @@ -0,0 +1,61 @@ +package api + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseFrontmatterSplitsHeaderAndBody(t *testing.T) { + src := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Title\n\nbody text\n" + fm, body := parseFrontmatter(src) + + assert.Equal(t, "jepa-fx", fm.get("wing")) + assert.Equal(t, "facts", fm.get("hall")) + assert.Equal(t, "2026-01-01T00:00:00Z", fm.get("created_at")) + assert.Equal(t, "# Title\n\nbody text\n", body) +} + +func TestParseFrontmatterNoHeader(t *testing.T) { + src := "# Just a body\n\nno frontmatter here\n" + fm, body := parseFrontmatter(src) + + assert.Empty(t, fm.lines) + assert.Equal(t, src, body) +} + +func TestFrontmatterSetOverridesExistingKey(t *testing.T) { + fm, _ := parseFrontmatter("---\nwing: a\nupdated_at: old\n---\nbody\n") + fm.set("updated_at", "new") + + assert.Equal(t, "new", fm.get("updated_at")) + // No duplicate key. + assert.Equal(t, 1, strings.Count(fm.render(), "updated_at:")) +} + +func TestFrontmatterSetAppendsNewKey(t *testing.T) { + fm, _ := parseFrontmatter("---\nwing: a\n---\nbody\n") + fm.set("supersedes", "abc123") + + out := fm.render() + assert.Contains(t, out, "wing: a") + assert.Contains(t, out, "supersedes: abc123") +} + +func TestFrontmatterRenderPreservesCustomFields(t *testing.T) { + src := "---\nwing: a\nhall: facts\ncustom_field: keep-me\ntags: [x, y]\n---\nbody\n" + fm, _ := parseFrontmatter(src) + fm.set("updated_at", "2026-06-22T00:00:00Z") + + out := fm.render() + assert.Contains(t, out, "custom_field: keep-me") + assert.Contains(t, out, "tags: [x, y]") + assert.Contains(t, out, "updated_at: 2026-06-22T00:00:00Z") +} + +func TestFrontmatterRenderRoundTrips(t *testing.T) { + src := "---\nwing: a\nhall: facts\n---\n" + fm, _ := parseFrontmatter(src) + assert.Equal(t, src, fm.render()) +} diff --git a/ingestion/internal/api/update.go b/ingestion/internal/api/update.go new file mode 100644 index 0000000..9fd88f8 --- /dev/null +++ b/ingestion/internal/api/update.go @@ -0,0 +1,131 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" +) + +// ContentHash returns the lowercase hex sha256 of b. It is the note's +// content_hash handle: brain_write / brain_update return it, brain_get +// recomputes it from the file on disk, and brain_update stamps the prior +// note's hash into the new note's `supersedes` frontmatter. +func ContentHash(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// resolveWithin maps a brainDir-relative path to an absolute path and +// guarantees it does not escape brainDir. Returns the cleaned relPath +// (forward-slashed) and the absolute path. +func resolveWithin(brainDir, relPath string) (rel, abs string, err error) { + clean := filepath.Clean("/" + filepath.ToSlash(relPath)) + rel = strings.TrimPrefix(clean, "/") + abs = filepath.Join(brainDir, filepath.FromSlash(rel)) + check, err := filepath.Rel(brainDir, abs) + if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("path %q escapes brain dir", relPath) + } + return rel, abs, nil +} + +// UpdateNoteOptions identifies the note to supersede and supplies its new +// body. Path takes precedence; otherwise the target is resolved from +// Wing/Hall/Slug via brain.NotePath. +type UpdateNoteOptions struct { + Path string // brainDir-relative path; takes precedence over wing/hall/slug + Wing string + Hall string + Slug string + Content string // new full body (whole-note replace) + Reason string // optional; stamped as supersede_reason +} + +// UpdateNote supersedes an existing note in place. It replaces the body +// with opts.Content, preserves the existing frontmatter (created_at, +// wing, hall, and any custom fields), and stamps updated_at, supersedes +// (the prior content hash), and supersede_reason (when given). +// +// It never creates: if the target does not exist, it returns an error so +// the caller can fall back to brain_write. Returns the note's relPath, +// the new content hash, and the prior content hash. +// +// Embeddings are NOT refreshed here. The rewritten file's mtime advances, +// which the mtime-driven vectorstore.Sync ticker uses to re-embed it on +// its next pass — the same out-of-band mechanism brain_write relies on. +func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, priorHash string, err error) { + if opts.Content == "" { + return "", "", "", fmt.Errorf("content is required") + } + + var rel string + if opts.Path != "" { + rel = opts.Path + } else { + full, perr := brain.NotePath(brainDir, opts.Wing, opts.Hall, opts.Slug) + if perr != nil { + return "", "", "", perr + } + rel, _ = filepath.Rel(brainDir, full) + rel = filepath.ToSlash(rel) + } + + rel, abs, err := resolveWithin(brainDir, rel) + if err != nil { + return "", "", "", err + } + + prior, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel) + } + return "", "", "", fmt.Errorf("read target: %w", err) + } + priorHash = ContentHash(prior) + + fm, _ := parseFrontmatter(string(prior)) + fm.set("updated_at", time.Now().UTC().Format(time.RFC3339)) + fm.set("supersedes", priorHash) + if opts.Reason != "" { + fm.set("supersede_reason", opts.Reason) + } + + out := []byte(fm.render() + opts.Content) + if err := os.WriteFile(abs, out, 0o644); err != nil { + return "", "", "", fmt.Errorf("write: %w", err) + } + return rel, ContentHash(out), priorHash, nil +} + +// ReadNote reads the note at the brainDir-relative relPath and returns +// its parsed frontmatter, body, and content hash. It is the read-after- +// write primitive behind brain_get: the hash it returns equals the hash +// brain_write / brain_update returned for the same bytes. +func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) { + _, abs, err := resolveWithin(brainDir, relPath) + if err != nil { + return nil, "", "", err + } + raw, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + return nil, "", "", fmt.Errorf("note %q does not exist", relPath) + } + return nil, "", "", fmt.Errorf("read note: %w", err) + } + parsed, body := parseFrontmatter(string(raw)) + fm = make(map[string]string, len(parsed.lines)) + for _, l := range parsed.lines { + if l.key != "" { + fm[l.key] = l.value + } + } + return fm, body, ContentHash(raw), nil +} diff --git a/ingestion/internal/api/update_test.go b/ingestion/internal/api/update_test.go new file mode 100644 index 0000000..d677a29 --- /dev/null +++ b/ingestion/internal/api/update_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedNote writes a note directly to disk and returns its relPath. +func seedNote(t *testing.T, brainDir, rel, content string) string { + t.Helper() + full := filepath.Join(brainDir, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) + return rel +} + +func TestUpdateNoteSupersedesAndStamps(t *testing.T) { + brainDir := t.TempDir() + rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", + "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n") + + relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{ + Path: rel, + Content: "# New\n\nnew body\n", + Reason: "facts changed", + }) + require.NoError(t, err) + assert.Equal(t, rel, relPath) + assert.NotEmpty(t, hash) + assert.NotEmpty(t, priorHash) + assert.NotEqual(t, hash, priorHash) + + got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel))) + require.NoError(t, err) + s := string(got) + // Body replaced. + assert.Contains(t, s, "# New") + assert.NotContains(t, s, "old body") + // Prior fields preserved. + assert.Contains(t, s, "wing: jepa-fx") + assert.Contains(t, s, "hall: facts") + assert.Contains(t, s, "created_at: 2026-01-01T00:00:00Z") + assert.Contains(t, s, "custom: keep-me") + // Supersession stamped. + assert.Contains(t, s, "updated_at:") + assert.Contains(t, s, "supersedes: "+priorHash) + assert.Contains(t, s, "supersede_reason: facts changed") +} + +func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) { + brainDir := t.TempDir() + seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", + "---\nwing: jepa-fx\nhall: facts\n---\nold\n") + + relPath, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ + Wing: "jepa-fx", Hall: "facts", Slug: "val-vol", + Content: "new\n", + }) + require.NoError(t, err) + assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", relPath) +} + +func TestUpdateNoteErrorsOnMissingAndDoesNotCreate(t *testing.T) { + brainDir := t.TempDir() + + _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ + Wing: "jepa-fx", Hall: "facts", Slug: "ghost", + Content: "x\n", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") + + // No file created. + _, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md")) + assert.True(t, os.IsNotExist(statErr), "missing-target update must not create a note") +} + +func TestUpdateNoteRejectsTraversal(t *testing.T) { + brainDir := t.TempDir() + _, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{ + Path: "../escape.md", + Content: "x\n", + }) + require.Error(t, err) +} + +func TestReadNoteReturnsFrontmatterBodyHash(t *testing.T) { + brainDir := t.TempDir() + rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/n.md", + "---\nwing: jepa-fx\nhall: facts\n---\n# Body\n\ntext\n") + + fm, body, hash, err := ReadNote(brainDir, rel) + require.NoError(t, err) + assert.Equal(t, "jepa-fx", fm["wing"]) + assert.Equal(t, "facts", fm["hall"]) + assert.Equal(t, "# Body\n\ntext\n", body) + + // Hash matches ContentHash of the raw bytes on disk (round-trip). + raw, _ := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel))) + assert.Equal(t, ContentHash(raw), hash) +} + +func TestReadNoteRejectsTraversal(t *testing.T) { + brainDir := t.TempDir() + _, _, _, err := ReadNote(brainDir, "../../etc/passwd") + require.Error(t, err) +} + +func TestUpdateThenReadRoundTripsHash(t *testing.T) { + brainDir := t.TempDir() + rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n") + + _, hash, _, err := UpdateNote(brainDir, UpdateNoteOptions{Path: rel, Content: "new\n"}) + require.NoError(t, err) + + _, _, readHash, err := ReadNote(brainDir, rel) + require.NoError(t, err) + assert.Equal(t, hash, readHash, "update content_hash must round-trip through ReadNote") +} + +func TestContentHashStable(t *testing.T) { + assert.Equal(t, ContentHash([]byte("abc")), ContentHash([]byte("abc"))) + assert.NotEqual(t, ContentHash([]byte("abc")), ContentHash([]byte("abd"))) + assert.True(t, strings.HasPrefix(ContentHash([]byte("")), "")) // hex, non-panicking +} From 6c61f93146d06f490982494caffa903335f5e3fb Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 22 Jun 2026 08:25:10 +0200 Subject: [PATCH 2/3] feat(mcp): register brain_update + brain_get, extend brain_write handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the #45 verbs into the MCP surface (all three sites: tools() descriptors, handleCall dispatch, package doc comment). - brain_update: supersede-by-slug or full path; rebuilds wing _index and re-tunnels cross-wing matches against the new body (idempotent, best-effort), re-indexes the graph, returns {id, path, content_hash, superseded}. - brain_get: fetch by id or path (both are the brain-relative handle); returns {id, path, content_hash, frontmatter, body}. - brain_write: return contract extended from {path} to {id, path, content_hash} — path kept for backward compat — so the create path also yields a stable handle. id == relPath; content_hash == sha256 of the file bytes. Tests cover the supersede happy path, missing-target error + no-create, get by id/path, write handle, and an end-to-end re-embed test that drives the real vectorstore.Sync re-index after an update. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/mcp/brain_update_test.go | 204 ++++++++++++++++++++ ingestion/internal/mcp/handlers.go | 126 +++++++++++- ingestion/internal/mcp/server.go | 10 +- ingestion/internal/mcp/server_test.go | 3 +- 4 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 ingestion/internal/mcp/brain_update_test.go diff --git a/ingestion/internal/mcp/brain_update_test.go b/ingestion/internal/mcp/brain_update_test.go new file mode 100644 index 0000000..742d4cb --- /dev/null +++ b/ingestion/internal/mcp/brain_update_test.go @@ -0,0 +1,204 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" + "github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// callResult parses the JSON text payload of a successful tool call. +func callResult(t *testing.T, resp map[string]any) map[string]any { + t.Helper() + require.Nil(t, resp["error"], "tool returned error: %v", resp["error"]) + text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &out)) + return out +} + +func TestBrainUpdateSupersedesExisting(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + + // Seed via brain_write so the note carries real frontmatter. + callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Old\n\nold body\n", "filename": "val-vol", + "wing": "jepa-fx", "hall": "facts", + })) + + out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{ + "wing": "jepa-fx", "hall": "facts", "slug": "val-vol", + "content": "# New\n\nnew body\n", "reason": "facts changed", + })) + assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", out["path"]) + assert.Equal(t, out["path"], out["id"]) + assert.NotEmpty(t, out["content_hash"]) + assert.Equal(t, true, out["superseded"]) + + got, err := os.ReadFile(filepath.Join(brainDir, "wiki/jepa-fx/facts/val-vol.md")) + require.NoError(t, err) + s := string(got) + assert.Contains(t, s, "# New") + assert.NotContains(t, s, "old body") + assert.Contains(t, s, "wing: jepa-fx") + assert.Contains(t, s, "supersede_reason: facts changed") + assert.Contains(t, s, "supersedes:") +} + +func TestBrainUpdateMissingTargetErrorsNoCreate(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + + resp := toolCall(t, srv, "brain_update", map[string]any{ + "wing": "jepa-fx", "hall": "facts", "slug": "ghost", + "content": "x\n", + }) + require.NotNil(t, resp["error"]) + assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "does not exist") + _, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestBrainUpdateByFullPath(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "old\n", "filename": "n", "wing": "a", "hall": "facts", + })) + + out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{ + "slug": "wiki/a/facts/n.md", "content": "fresh\n", + })) + assert.Equal(t, "wiki/a/facts/n.md", out["path"]) +} + +func TestBrainGetByIDAndPath(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + w := callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Body\n\ntext\n", "filename": "n", "wing": "a", "hall": "facts", + })) + id := w["id"].(string) + hash := w["content_hash"].(string) + require.NotEmpty(t, id) + require.NotEmpty(t, hash) + + // by id + g1 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"id": id})) + assert.Equal(t, id, g1["path"]) + assert.Equal(t, hash, g1["content_hash"], "content_hash must round-trip write→get") + assert.Contains(t, g1["body"].(string), "# Body") + fm := g1["frontmatter"].(map[string]any) + assert.Equal(t, "a", fm["wing"]) + + // by path + g2 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"path": id})) + assert.Equal(t, hash, g2["content_hash"]) +} + +func TestBrainGetMissingArgsErrors(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) + resp := toolCall(t, srv, "brain_get", map[string]any{}) + require.NotNil(t, resp["error"]) +} + +func TestBrainWriteReturnsHandle(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + out := callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# X\n\nbody\n", "filename": "x", "wing": "a", "hall": "facts", + })) + assert.Equal(t, "wiki/a/facts/x.md", out["path"]) + assert.Equal(t, out["path"], out["id"]) + assert.NotEmpty(t, out["content_hash"]) +} + +// --- retrieval-reflects-new-content: exercises the real mtime-driven Sync --- + +type fakeVecStore struct { + chunks map[string][]float32 + deleted []string +} + +func (f *fakeVecStore) KnownPathsWithTime(_ context.Context) (map[string]time.Time, error) { + m := make(map[string]time.Time, len(f.chunks)) + for p := range f.chunks { + m[p] = time.Unix(0, 0) // always stale → mtime(now) is always newer + } + return m, nil +} + +func (f *fakeVecStore) Upsert(_ context.Context, path string, vec []float32) error { + f.chunks[path] = vec + return nil +} + +func (f *fakeVecStore) Delete(_ context.Context, path string) error { + delete(f.chunks, path) + f.deleted = append(f.deleted, path) + return nil +} + +type fakeEmbedder struct{ seen []string } + +func (e *fakeEmbedder) Embed(_ context.Context, text string) ([]float32, error) { + e.seen = append(e.seen, text) + return []float32{1, 0, 0}, nil +} + +// TestBrainUpdateReembedsNewContent proves the supersede contract end to +// end against the actual embedding mechanism: brain_update rewrites the +// file, advancing its mtime, and the next vectorstore.Sync pass re-embeds +// the NEW body and drops the stale chunk. No stub of the re-index path. +func TestBrainUpdateReembedsNewContent(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + ctx := context.Background() + + callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Note\n\nthe OLD distinctive payload\n", + "filename": "n", "wing": "a", "hall": "facts", + })) + + store := &fakeVecStore{chunks: map[string][]float32{}} + emb := &fakeEmbedder{} + + // First sync embeds the original content. + _, err := vectorstore.Sync(ctx, brainDir, store, emb) + require.NoError(t, err) + require.NotEmpty(t, store.chunks) + require.True(t, anyContains(emb.seen, "OLD distinctive payload")) + + callResult(t, toolCall(t, srv, "brain_update", map[string]any{ + "wing": "a", "hall": "facts", "slug": "n", + "content": "# Note\n\nthe NEW distinctive payload\n", + })) + + emb.seen = nil // only watch what the second pass embeds + _, err = vectorstore.Sync(ctx, brainDir, store, emb) + require.NoError(t, err) + + assert.True(t, anyContains(emb.seen, "NEW distinctive payload"), + "Sync must re-embed the superseded body; saw %v", emb.seen) + assert.False(t, anyContains(emb.seen, "OLD distinctive payload"), + "the old body must not be re-embedded") + assert.NotEmpty(t, store.deleted, "stale chunks must be deleted before re-embed") +} + +func anyContains(ss []string, sub string) bool { + for _, s := range ss { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 573cd06..8966b9b 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -61,6 +61,26 @@ func (s *Server) tools() []map[string]any { "hall": enum("optional memory type (requires wing)", halls...), }), }, + { + "name": "brain_update", + "description": "Supersede an existing brain note in place: whole-note body replace + frontmatter re-stamp (updated_at, supersedes=prior content hash, supersede_reason). Errors if the target does not exist — use brain_write to create. Returns {id, path, content_hash, superseded}. Prior version recoverable from git.", + "inputSchema": schema([]string{"content"}, map[string]any{ + "content": str("new full body (whole-note replace)"), + "slug": str("target note slug within wing/hall, OR a full brain-relative path (e.g. wiki/jepa-fx/facts/x.md)"), + "wing": str("wing of the target (required unless slug/path is a full path)"), + "hall": enum("hall of the target (required unless slug/path is a full path)", halls...), + "path": str("full brain-relative path to the target; takes precedence over slug/wing/hall"), + "reason": str("optional short note on why superseded — stamped into frontmatter"), + }), + }, + { + "name": "brain_get", + "description": "Fetch a single brain note by id or path (both are the brain-relative path — the note handle). Returns {id, path, content_hash, frontmatter, body}. Read-after-write confirmation without a lexical re-query.", + "inputSchema": schema([]string{}, map[string]any{ + "id": str("note id (brain-relative path) as returned by brain_write/brain_update"), + "path": str("brain-relative path to the note; equivalent to id"), + }), + }, { "name": "brain_tunnel", "description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.", @@ -222,7 +242,111 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw } } s.indexInGraph(ctx, "brain_write", relPath) - return json.Marshal(map[string]string{"path": relPath}) + // Read-after-write handle: id == relPath, content_hash == sha256 of + // the bytes just written. path is kept for backward compatibility. + _, _, hash, _ := api.ReadNote(s.brainDir, relPath) + return json.Marshal(map[string]string{"id": relPath, "path": relPath, "content_hash": hash}) +} + +type brainUpdateArgs struct { + Slug string `json:"slug,omitempty"` + Wing string `json:"wing,omitempty"` + Hall string `json:"hall,omitempty"` + Path string `json:"path,omitempty"` + Content string `json:"content"` + Reason string `json:"reason,omitempty"` +} + +// brainUpdate supersedes an existing note in place: whole-note body +// replace, frontmatter re-stamp (updated_at/supersedes/supersede_reason), +// graph re-index, and wing _index rebuild. It never creates — a missing +// target is an error so the caller can fall back to brain_write. +// +// Embedding re-sync is delegated to the out-of-band vectorstore.Sync +// ticker: the rewritten file's mtime advances, so the next pass re-embeds +// it. This mirrors brain_write, which likewise does not embed in-handler. +func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainUpdateArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Content == "" { + return nil, fmt.Errorf("content is required") + } + + opts := api.UpdateNoteOptions{Content: a.Content, Reason: a.Reason} + switch { + case a.Path != "": + opts.Path = a.Path + case strings.Contains(a.Slug, "/"): + // slug carries a full path (issue #45: "slug ... OR full path"). + opts.Path = a.Slug + default: + opts.Wing, opts.Hall, opts.Slug = a.Wing, a.Hall, a.Slug + } + + relPath, hash, _, err := api.UpdateNote(s.brainDir, opts) + if err != nil { + return nil, err + } + + // Best-effort wiki upkeep, mirroring brain_write: rebuild the wing + // _index and re-tunnel cross-wing matches against the new body. Both + // are idempotent and never block — the note is already superseded. + if wing := wingFromRelPath(relPath); wing != "" { + if err := brain.BuildWingIndex(s.brainDir, wing); err != nil { + slog.Warn("brain_update: auto-index failed", "wing", wing, "err", err) + } + if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil { + slog.Warn("brain_update: auto-tunnel failed", "src", relPath, "err", err) + } + } + s.indexInGraph(ctx, "brain_update", relPath) + + return json.Marshal(map[string]any{ + "id": relPath, "path": relPath, "content_hash": hash, "superseded": true, + }) +} + +// wingFromRelPath extracts the wing segment from a structured wiki path +// (wiki///.md). Returns "" for legacy/non-wiki paths. +func wingFromRelPath(relPath string) string { + parts := strings.Split(relPath, "/") + if len(parts) >= 4 && parts[0] == "wiki" { + return parts[1] + } + return "" +} + +type brainGetArgs struct { + ID string `json:"id,omitempty"` + Path string `json:"path,omitempty"` +} + +// brainGet fetches a note by id or path (both are the brainDir-relative +// path — the de-facto handle). Read-only; the create-path read-after- +// write primitive that lets callers confirm a write landed without a +// lexical re-query. +func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainGetArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + target := a.Path + if target == "" { + target = a.ID + } + if target == "" { + return nil, fmt.Errorf("id or path is required") + } + fm, body, hash, err := api.ReadNote(s.brainDir, target) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "id": target, "path": target, "content_hash": hash, + "frontmatter": fm, "body": body, + }) } // indexInGraph is a best-effort wrapper around graphsync.IndexDoc that diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 0ba1cc0..0f9051d 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -1,7 +1,7 @@ // Package mcp implements an MCP HTTP handler for the ingestion service. -// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel, -// brain_ingest, brain_ingest_raw, brain_answer, brain_classify, -// brain_graph, brain_context, session_log. +// Exposed tools: brain_query, brain_write, brain_update, brain_get, +// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw, +// brain_answer, brain_classify, brain_graph, brain_context, session_log. package mcp import ( @@ -177,6 +177,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa return s.brainQuery(ctx, args) case "brain_write": return s.brainWrite(ctx, args) + case "brain_update": + return s.brainUpdate(ctx, args) + case "brain_get": + return s.brainGet(ctx, args) case "brain_index": return s.brainIndex(ctx, args) case "brain_tunnel": diff --git a/ingestion/internal/mcp/server_test.go b/ingestion/internal/mcp/server_test.go index 4baf35e..6d6494d 100644 --- a/ingestion/internal/mcp/server_test.go +++ b/ingestion/internal/mcp/server_test.go @@ -55,7 +55,8 @@ func TestServerToolsList(t *testing.T) { names = append(names, t.(map[string]any)["name"].(string)) } assert.ElementsMatch(t, []string{ - "brain_query", "brain_write", "brain_index", "brain_tunnel", + "brain_query", "brain_write", "brain_update", "brain_get", + "brain_index", "brain_tunnel", "brain_ingest_raw", "brain_ingest", "brain_answer", "brain_classify", "brain_graph", "brain_context", "session_log", From f04b03e07ea55ea561370e414473de41e5aaa13b Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 22 Jun 2026 08:25:16 +0200 Subject: [PATCH 3/3] chore(context): re-sync derived adapters after root rule-0 update context-sync regenerated the adapters from the updated root AGENT.md (rule 0 pre-task ritual + TDD constraint). The committed adapters had drifted; this is the documented `task check` remedy, not a content change in this repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .aider.conventions.md | 62 +++++++++++++++++++++++++++++++++----- .context/system-prompt.txt | 62 +++++++++++++++++++++++++++++++++----- .cursorrules | 62 +++++++++++++++++++++++++++++++++----- AGENTS.md | 62 +++++++++++++++++++++++++++++++++----- 4 files changed, 216 insertions(+), 32 deletions(-) diff --git a/.aider.conventions.md b/.aider.conventions.md index f0519f4..29dca29 100644 --- a/.aider.conventions.md +++ b/.aider.conventions.md @@ -27,6 +27,14 @@ and climate/sustainability tech. These rules apply to every task across every project, regardless of harness. +0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line: + - **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours. + - **Load the relevant skill** — see trigger table in *Engineering Skills* below. + - **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test. + - **State the observable success criterion** — what specific behavior, output, or passing test proves this is done? + + **TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it. + 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. Think before coding; if the problem is unclear, ask or state assumptions before acting. 2. **Minimum viable code.** Solve with the smallest change that works. Nothing @@ -49,6 +57,22 @@ These rules apply to every task across every project, regardless of harness. PR flow only when a human reviewer outside the project is required. Document the reason in PROJECT.md. +6. **Close the loop — every substantive task ends with the same ritual.** Shipping + the code is not the end of the task; capturing it is. Run this unprompted: + - **Tag + bump SemVer** on the change (annotated tag; minor for a feature or + new/changed ADR, patch for a fix; docs in the same commit). Check the repo's + actual last tag — stated versions in docs drift stale. + - **Push** main and the tag (CI is the gate). + - **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) — + the reusable patterns and the footguns that would bite anyone again, never + project status. See *Knowledge base — when to write* below. + - **File discovered-but-deferred work as tracker issues** on the project's own + repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let + "out of scope, recorded" rot in a commit message; make it a ticket with a + source pointer. + - Surface the brain entries and issue numbers in the closing summary so the + trail is auditable. + ## Default stack | Layer | Default | Fallback | Last resort | @@ -78,6 +102,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +## Secret handling (every harness, every command) + +Tool output is persisted: terminal → `~/.claude/projects` transcripts → +claudewatcher → brain/wiki → gitea history. A secret printed once is +searchable forever, and clearing it means rotating the key. So: + +1. **Never print, echo, log, or transform a secret to inspect it.** No + `base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform + to defeat `op run`'s output masking (it masks raw values; base64 hides them + from the mask — that exact trick leaked a key on 2026-06-11). +2. **Secrets stay in the subprocess.** Reference them only as env vars consumed + *inside* `op run --env-file ~/.op-env -- `. Never place a literal secret + in a command's argv (it lands in the tool call and the transcript). +3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` — + never `${X:-...}` (returns the value when set) and never echo a substring of it. +4. **Cross-host secrets:** run the secret-consuming command on the host that has + the secret; do not forward a raw key over ssh argv/stdout. +5. If a secret does leak into output, say so immediately and flag it for rotation — + don't bury it. + ## Infrastructure Three machines on Tailscale: @@ -157,7 +201,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*. | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | -| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | +| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as @@ -219,15 +263,17 @@ unconditionally on every host, every harness. ## Engineering Skills -Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. +Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. -See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. +**Skill trigger table — load before starting, not after getting stuck:** -Key skills: -- **TDD**: always write tests first — load `tdd` skill -- **Code Review**: load `code-review` skill before any review -- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work -- **Problem first**: load `problem-analysis` skill before coding non-trivial features +| Task type | Load | +|-----------|------| +| Any feature or bug fix | `tdd` | +| Refactor or design | `clean-code` or `solid` | +| Debug | `problem-analysis` | +| Review code or PRs | `code-review` | +| Frame a problem before coding | `problem-analysis` | --- diff --git a/.context/system-prompt.txt b/.context/system-prompt.txt index 3349850..51498e4 100644 --- a/.context/system-prompt.txt +++ b/.context/system-prompt.txt @@ -32,6 +32,14 @@ and climate/sustainability tech. These rules apply to every task across every project, regardless of harness. +0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line: + - **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours. + - **Load the relevant skill** — see trigger table in *Engineering Skills* below. + - **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test. + - **State the observable success criterion** — what specific behavior, output, or passing test proves this is done? + + **TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it. + 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. Think before coding; if the problem is unclear, ask or state assumptions before acting. 2. **Minimum viable code.** Solve with the smallest change that works. Nothing @@ -54,6 +62,22 @@ These rules apply to every task across every project, regardless of harness. PR flow only when a human reviewer outside the project is required. Document the reason in PROJECT.md. +6. **Close the loop — every substantive task ends with the same ritual.** Shipping + the code is not the end of the task; capturing it is. Run this unprompted: + - **Tag + bump SemVer** on the change (annotated tag; minor for a feature or + new/changed ADR, patch for a fix; docs in the same commit). Check the repo's + actual last tag — stated versions in docs drift stale. + - **Push** main and the tag (CI is the gate). + - **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) — + the reusable patterns and the footguns that would bite anyone again, never + project status. See *Knowledge base — when to write* below. + - **File discovered-but-deferred work as tracker issues** on the project's own + repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let + "out of scope, recorded" rot in a commit message; make it a ticket with a + source pointer. + - Surface the brain entries and issue numbers in the closing summary so the + trail is auditable. + ## Default stack | Layer | Default | Fallback | Last resort | @@ -83,6 +107,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +## Secret handling (every harness, every command) + +Tool output is persisted: terminal → `~/.claude/projects` transcripts → +claudewatcher → brain/wiki → gitea history. A secret printed once is +searchable forever, and clearing it means rotating the key. So: + +1. **Never print, echo, log, or transform a secret to inspect it.** No + `base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform + to defeat `op run`'s output masking (it masks raw values; base64 hides them + from the mask — that exact trick leaked a key on 2026-06-11). +2. **Secrets stay in the subprocess.** Reference them only as env vars consumed + *inside* `op run --env-file ~/.op-env -- `. Never place a literal secret + in a command's argv (it lands in the tool call and the transcript). +3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` — + never `${X:-...}` (returns the value when set) and never echo a substring of it. +4. **Cross-host secrets:** run the secret-consuming command on the host that has + the secret; do not forward a raw key over ssh argv/stdout. +5. If a secret does leak into output, say so immediately and flag it for rotation — + don't bury it. + ## Infrastructure Three machines on Tailscale: @@ -162,7 +206,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*. | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | -| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | +| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as @@ -224,15 +268,17 @@ unconditionally on every host, every harness. ## Engineering Skills -Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. +Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. -See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. +**Skill trigger table — load before starting, not after getting stuck:** -Key skills: -- **TDD**: always write tests first — load `tdd` skill -- **Code Review**: load `code-review` skill before any review -- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work -- **Problem first**: load `problem-analysis` skill before coding non-trivial features +| Task type | Load | +|-----------|------| +| Any feature or bug fix | `tdd` | +| Refactor or design | `clean-code` or `solid` | +| Debug | `problem-analysis` | +| Review code or PRs | `code-review` | +| Frame a problem before coding | `problem-analysis` | --- diff --git a/.cursorrules b/.cursorrules index ea01e8f..3c3c05b 100644 --- a/.cursorrules +++ b/.cursorrules @@ -30,6 +30,14 @@ and climate/sustainability tech. These rules apply to every task across every project, regardless of harness. +0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line: + - **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours. + - **Load the relevant skill** — see trigger table in *Engineering Skills* below. + - **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test. + - **State the observable success criterion** — what specific behavior, output, or passing test proves this is done? + + **TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it. + 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. Think before coding; if the problem is unclear, ask or state assumptions before acting. 2. **Minimum viable code.** Solve with the smallest change that works. Nothing @@ -52,6 +60,22 @@ These rules apply to every task across every project, regardless of harness. PR flow only when a human reviewer outside the project is required. Document the reason in PROJECT.md. +6. **Close the loop — every substantive task ends with the same ritual.** Shipping + the code is not the end of the task; capturing it is. Run this unprompted: + - **Tag + bump SemVer** on the change (annotated tag; minor for a feature or + new/changed ADR, patch for a fix; docs in the same commit). Check the repo's + actual last tag — stated versions in docs drift stale. + - **Push** main and the tag (CI is the gate). + - **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) — + the reusable patterns and the footguns that would bite anyone again, never + project status. See *Knowledge base — when to write* below. + - **File discovered-but-deferred work as tracker issues** on the project's own + repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let + "out of scope, recorded" rot in a commit message; make it a ticket with a + source pointer. + - Surface the brain entries and issue numbers in the closing summary so the + trail is auditable. + ## Default stack | Layer | Default | Fallback | Last resort | @@ -81,6 +105,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +## Secret handling (every harness, every command) + +Tool output is persisted: terminal → `~/.claude/projects` transcripts → +claudewatcher → brain/wiki → gitea history. A secret printed once is +searchable forever, and clearing it means rotating the key. So: + +1. **Never print, echo, log, or transform a secret to inspect it.** No + `base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform + to defeat `op run`'s output masking (it masks raw values; base64 hides them + from the mask — that exact trick leaked a key on 2026-06-11). +2. **Secrets stay in the subprocess.** Reference them only as env vars consumed + *inside* `op run --env-file ~/.op-env -- `. Never place a literal secret + in a command's argv (it lands in the tool call and the transcript). +3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` — + never `${X:-...}` (returns the value when set) and never echo a substring of it. +4. **Cross-host secrets:** run the secret-consuming command on the host that has + the secret; do not forward a raw key over ssh argv/stdout. +5. If a secret does leak into output, say so immediately and flag it for rotation — + don't bury it. + ## Infrastructure Three machines on Tailscale: @@ -160,7 +204,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*. | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | -| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | +| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as @@ -222,15 +266,17 @@ unconditionally on every host, every harness. ## Engineering Skills -Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. +Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. -See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. +**Skill trigger table — load before starting, not after getting stuck:** -Key skills: -- **TDD**: always write tests first — load `tdd` skill -- **Code Review**: load `code-review` skill before any review -- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work -- **Problem first**: load `problem-analysis` skill before coding non-trivial features +| Task type | Load | +|-----------|------| +| Any feature or bug fix | `tdd` | +| Refactor or design | `clean-code` or `solid` | +| Debug | `problem-analysis` | +| Review code or PRs | `code-review` | +| Frame a problem before coding | `problem-analysis` | --- diff --git a/AGENTS.md b/AGENTS.md index f0519f4..29dca29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,14 @@ and climate/sustainability tech. These rules apply to every task across every project, regardless of harness. +0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line: + - **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours. + - **Load the relevant skill** — see trigger table in *Engineering Skills* below. + - **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test. + - **State the observable success criterion** — what specific behavior, output, or passing test proves this is done? + + **TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it. + 1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly. Think before coding; if the problem is unclear, ask or state assumptions before acting. 2. **Minimum viable code.** Solve with the smallest change that works. Nothing @@ -49,6 +57,22 @@ These rules apply to every task across every project, regardless of harness. PR flow only when a human reviewer outside the project is required. Document the reason in PROJECT.md. +6. **Close the loop — every substantive task ends with the same ritual.** Shipping + the code is not the end of the task; capturing it is. Run this unprompted: + - **Tag + bump SemVer** on the change (annotated tag; minor for a feature or + new/changed ADR, patch for a fix; docs in the same commit). Check the repo's + actual last tag — stated versions in docs drift stale. + - **Push** main and the tag (CI is the gate). + - **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) — + the reusable patterns and the footguns that would bite anyone again, never + project status. See *Knowledge base — when to write* below. + - **File discovered-but-deferred work as tracker issues** on the project's own + repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let + "out of scope, recorded" rot in a commit message; make it a ticket with a + source pointer. + - Surface the brain entries and issue numbers in the closing summary so the + trail is auditable. + ## Default stack | Layer | Default | Fallback | Last resort | @@ -78,6 +102,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these. - **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config - **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message +## Secret handling (every harness, every command) + +Tool output is persisted: terminal → `~/.claude/projects` transcripts → +claudewatcher → brain/wiki → gitea history. A secret printed once is +searchable forever, and clearing it means rotating the key. So: + +1. **Never print, echo, log, or transform a secret to inspect it.** No + `base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform + to defeat `op run`'s output masking (it masks raw values; base64 hides them + from the mask — that exact trick leaked a key on 2026-06-11). +2. **Secrets stay in the subprocess.** Reference them only as env vars consumed + *inside* `op run --env-file ~/.op-env -- `. Never place a literal secret + in a command's argv (it lands in the tool call and the transcript). +3. **Existence check without revealing the value:** `[ -n "$X" ] && echo set` — + never `${X:-...}` (returns the value when set) and never echo a substring of it. +4. **Cross-host secrets:** run the secret-consuming command on the host that has + the secret; do not forward a raw key over ssh argv/stdout. +5. If a secret does leak into output, say so immediately and flag it for rotation — + don't bury it. + ## Infrastructure Three machines on Tailscale: @@ -157,7 +201,7 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*. | **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool | | **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same | | **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` | -| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | +| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files | - **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`. - **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as @@ -219,15 +263,17 @@ unconditionally on every host, every harness. ## Engineering Skills -Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index. +Shared engineering skills are available in `~/dev/.skills/`. Load at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list. -See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers. +**Skill trigger table — load before starting, not after getting stuck:** -Key skills: -- **TDD**: always write tests first — load `tdd` skill -- **Code Review**: load `code-review` skill before any review -- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work -- **Problem first**: load `problem-analysis` skill before coding non-trivial features +| Task type | Load | +|-----------|------| +| Any feature or bug fix | `tdd` | +| Refactor or design | `clean-code` or `solid` | +| Debug | `problem-analysis` | +| Review code or PRs | `code-review` | +| Frame a problem before coding | `problem-analysis` | ---