From 35e16ecaf1ac16b7009857d5b22d73df5badb5a7 Mon Sep 17 00:00:00 2001 From: Luc Date: Fri, 18 Sep 2026 10:02:37 +0000 Subject: [PATCH 1/5] feat(memory): derive display titles from a memory's own fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Memory.Title(), which every display path now shares. Effectiveness memories previously rendered as "## Outcomes" — their body's first line is a markdown heading, not a summary — so they reached users and models as a stray fragment. Their identity is structured, so the title is built from persona and domain rather than parsed back out of prose. - Removes the duplicated firstLine helpers in recall and the MCP handlers - Keeps recall, the MCP tools, and the session-start hook from drifting into three different answers for the same memory --- internal/cmd/recall.go | 11 ++----- internal/mcp/handlers.go | 11 +------ internal/memory/memory.go | 27 +++++++++++++++- internal/memory/memory_test.go | 57 ++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 internal/memory/memory_test.go diff --git a/internal/cmd/recall.go b/internal/cmd/recall.go index 7c5f858..dfaac6b 100644 --- a/internal/cmd/recall.go +++ b/internal/cmd/recall.go @@ -53,7 +53,7 @@ func recallCmd() *cobra.Command { Path: m.Path, Layer: string(m.Layer), Domain: m.Domain, - Title: firstLine(m.Body), + Title: m.Title(), Body: m.Body, Tags: m.Tags, }) @@ -65,7 +65,7 @@ func recallCmd() *cobra.Command { if i > 0 { sb.WriteString("\n") } - fmt.Fprintf(&sb, "[%s] %s\n", m.Layer, firstLine(m.Body)) + fmt.Fprintf(&sb, "[%s] %s\n", m.Layer, m.Title()) fmt.Fprintf(&sb, " domain: %s path: %s\n", m.Domain, m.Path) } return sb.String() @@ -79,10 +79,3 @@ func recallCmd() *cobra.Command { cmd.Flags().IntVar(&limit, "limit", 5, "max results") return cmd } - -func firstLine(s string) string { - if i := strings.IndexByte(s, '\n'); i >= 0 { - return s[:i] - } - return s -} diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 6003b93..0b0faa0 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -124,7 +124,7 @@ func (s *Server) handleRecall(ctx context.Context, args map[string]any) toolCall "path": m.Path, "layer": string(m.Layer), "domain": m.Domain, - "title": firstLine(m.Body), + "title": m.Title(), "body": m.Body, } if len(m.Tags) > 0 { @@ -229,12 +229,3 @@ func jsonResult(v any) toolCallResult { Content: []toolContent{{Type: "text", Text: string(data)}}, } } - -// firstLine returns the first line of s. -func firstLine(s string) string { - if i := strings.IndexByte(s, '\n'); i >= 0 { - return s[:i] - } - return s -} - diff --git a/internal/memory/memory.go b/internal/memory/memory.go index b19a5fc..1a1aa80 100644 --- a/internal/memory/memory.go +++ b/internal/memory/memory.go @@ -1,6 +1,9 @@ package memory -import "time" +import ( + "strings" + "time" +) // Layer identifies which of the five memory layers a memory belongs to. type Layer string @@ -83,3 +86,25 @@ type Memory struct { // Body is the markdown content below the frontmatter. Body string `yaml:"-"` } + +// Title returns a one-line label for the memory, for display. +// +// Most layers use the first line of the body, which is how the memory was +// written and therefore the best summary of it. Effectiveness is the +// exception: its body opens with the "## Outcomes" list, so its first line +// is a markdown heading that says nothing about the memory. Its identity is +// structured instead — a persona and a domain — so the title is built from +// those rather than parsed back out of prose. +// +// Deriving here rather than in each caller keeps recall, the MCP tools, and +// the session-start hook from drifting into three different answers for the +// same memory. +func (m Memory) Title() string { + if m.Layer == LayerEffectiveness && m.Persona != "" { + return m.Persona + " effectiveness in " + m.Domain + } + if i := strings.IndexByte(m.Body, '\n'); i >= 0 { + return m.Body[:i] + } + return m.Body +} diff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go new file mode 100644 index 0000000..15e1b4c --- /dev/null +++ b/internal/memory/memory_test.go @@ -0,0 +1,57 @@ +package memory + +import "testing" + +func TestMemory_Title(t *testing.T) { + tests := []struct { + name string + m Memory + want string + }{ + { + // The case this method exists for. An effectiveness body opens + // with its "## Outcomes" list, so the first line is a markdown + // heading that says nothing about the memory. + name: "effectiveness uses persona and domain", + m: Memory{ + Layer: LayerEffectiveness, + Domain: "payments", + Persona: "kent-beck", + Body: "## Outcomes\n- 2026-04-14: accepted", + }, + want: "kent-beck effectiveness in payments", + }, + { + name: "effectiveness with no persona falls back to the body", + m: Memory{ + Layer: LayerEffectiveness, + Domain: "payments", + Body: "## Outcomes\n- 2026-04-14: accepted", + }, + want: "## Outcomes", + }, + { + name: "fact uses the first line", + m: Memory{Layer: LayerFact, Body: "The users table has 12M rows\n\nMore detail."}, + want: "The users table has 12M rows", + }, + { + name: "single-line body is the whole body", + m: Memory{Layer: LayerCorrection, Body: "Stop flagging nullable email"}, + want: "Stop flagging nullable email", + }, + { + name: "empty body", + m: Memory{Layer: LayerFact}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.m.Title(); got != tt.want { + t.Errorf("Title() = %q, want %q", got, tt.want) + } + }) + } +} From 8df0ff111e281304574e206d33a8c08a1e41dccb Mon Sep 17 00:00:00 2001 From: Luc Date: Fri, 18 Sep 2026 10:02:43 +0000 Subject: [PATCH 2/5] feat(cli): add brain hooks for editor integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds brain hooks, which prints the configuration that makes the memory loop run by itself, and brain hooks session-start, the hook that injects a project's highest-authority memories into a new session. brAIn prints and the human installs — it never edits a settings file someone else owns. - session-start always exits 0: a hook exiting 2 blocks the session, and an empty or absent .brain/ is ordinary rather than exceptional - --tool cursor emits .cursor/rules/brain.mdc; the default emits Claude Code's settings.json block and the CLAUDE.md rules - The settings block carries a timeout, since nothing inside the hook can bound a wedged recall portably - hooks joins the commands that run without a .brain/, building its own engine per the contract documented in root.go --- internal/cmd/hooks.go | 355 ++++++++++++++++++++++++++++++++++++++++++ internal/cmd/root.go | 13 +- 2 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/hooks.go diff --git a/internal/cmd/hooks.go b/internal/cmd/hooks.go new file mode 100644 index 0000000..efeee29 --- /dev/null +++ b/internal/cmd/hooks.go @@ -0,0 +1,355 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/luuuc/brain/internal/config" + "github.com/luuuc/brain/internal/engine" + "github.com/luuuc/brain/internal/markdown" + "github.com/luuuc/brain/internal/memory" + "github.com/luuuc/brain/internal/trust" +) + +// sessionStartLimit is how many memories a session opens with. Every session +// pays for these in tokens before anyone types anything, so the number stays +// small deliberately: a hook that injects thirty memories teaches a model to +// skim them. Anything beyond the top five is the model's job to ask for, by +// domain, once it knows what it is touching. +const sessionStartLimit = 5 + +const sessionStartLongHelp = `Print project memory for an editor's session-start hook. + +Writes the project's highest-authority memories to stdout, framed so a model +knows what outranks what. Claude Code adds a SessionStart hook's stdout to +the model's context, so this is what a session knows before anyone types. + +It always exits 0 and it never prints a diagnostic. A session-start hook +that exits 2 blocks the session, and the states that would cause it — no +.brain/ directory, an empty one, an unreadable config — are ordinary rather +than exceptional. Having nothing to say is not an error; it prints nothing. + +No domain is passed on purpose. A hook cannot know which domain a session is +about, and brAIn does not guess. Recall with no domain ranks across all of +them by layer authority, which is the right shape for "here is what is +non-negotiable in this project". + +Output is plain text, not JSON, and deliberately so: the hook payload schema +belongs to the editor, and brAIn should not have to ship a release when +someone else's protocol changes.` + +const sessionStartHeader = `# Project memory (brAIn) + +Ranked by authority, most authoritative first. Corrections are +non-negotiable owner directives. Decisions are settled — do not relitigate +them unless their revisit condition is met. + +` + +const sessionStartFooter = `Before working in a specific area, run ` + + "`brain recall --domain `" + ` for the memories that apply to it. +` + +// claudeSettingsSnippet is the SessionStart block for .claude/settings.json. +// +// The timeout is load-bearing rather than decoration. Nothing inside the +// hook can bound how long a recall takes — "timeout" is not portable to +// macOS, which is half of brAIn's supported platforms — so the only place +// the bound can live is here, in the editor's own configuration. Without it +// a wedged recall stalls the session behind it. +// +// Ten seconds is generous for reading a handful of markdown files, and short +// enough that a person notices the stall rather than assuming the editor +// hung. +// +// The matcher covers the three events that leave a session without memory: +// it starts, it is cleared, or it is compacted. Compaction is the one most +// worth having — that is where a correction gets summarised away and the +// model quietly loses the thing it was told not to do. "resume" and "fork" +// are excluded because both inherit a context that already has the +// injection in it. +const claudeSettingsSnippet = `{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|clear|compact", + "hooks": [ + { + "type": "command", + "command": "brain hooks session-start", + "timeout": 10 + } + ] + } + ] + } +}` + +const hooksLongHelp = `Print the editor configuration that runs the memory loop. + +Everything is written to stdout. brAIn never edits your settings, your +CLAUDE.md, or anything else you own — it prints, you install. Merging into a +settings file you have already customised is your editor's problem to get +right, not a memory tool's.` + +// The editors brAIn knows how to set up. +const ( + toolClaudeCode = "claude-code" + toolCursor = "cursor" +) + +func hooksCmd() *cobra.Command { + var tool string + + cmd := &cobra.Command{ + Use: "hooks", + Short: "Print editor configuration for the memory loop", + Long: hooksLongHelp, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + switch tool { + case toolClaudeCode: + fmt.Print(claudeCodeSetup()) + case toolCursor: + fmt.Print(cursorSetup()) + default: + return &ExitError{Code: 3, Err: fmt.Errorf( + "unknown tool %q (valid: %s, %s)", tool, toolClaudeCode, toolCursor)} + } + return nil + }, + } + cmd.Flags().StringVar(&tool, "tool", toolClaudeCode, + "editor to print setup for (claude-code, cursor)") + cmd.AddCommand(sessionStartCmd()) + return cmd +} + +// cursorFrontmatter is the .mdc header Cursor reads. +// +// alwaysApply is true because memory is not the kind of context that belongs +// to particular files. Scoping it with globs would hide it precisely when +// someone works somewhere nobody predicted, which is when a correction is +// most worth having. +const cursorFrontmatter = `--- +description: Project memory (brAIn) — recall before changing code, record what compounds +globs: +alwaysApply: true +---` + +// cursorSetup renders the install instructions for Cursor. +func cursorSetup() string { + var b strings.Builder + b.WriteString(`# brAIn in Cursor + +Save this as .cursor/rules/brain.mdc in your project: + +`) + b.WriteString(cursorFrontmatter) + b.WriteString("\n\n") + b.WriteString(memoryRules(cursorIntro)) + return b.String() +} + +// claudeCodeSetup renders the install instructions for Claude Code. +func claudeCodeSetup() string { + var b strings.Builder + b.WriteString(`# brAIn in Claude Code + +1. Add this to .claude/settings.json in your project: + +`) + b.WriteString(claudeSettingsSnippet) + b.WriteString(` + +Put it in the project rather than in ~/.claude/settings.json. A global hook +runs in every project you open, including the ones with no .brain/ — it +costs nothing there, but the project file is also the one that travels with +the repository, so the rest of the team gets the same memory you do. + +The matcher covers the three events that leave a session with no memory: +it starts, it is cleared, or it is compacted. Compaction matters most — it +is where a correction gets summarised away and the model quietly loses the +thing it was told not to do. Resumed and forked sessions are left out +because they inherit a context that already has it. + +2. Add this to your CLAUDE.md: + +`) + b.WriteString(memoryRules(claudeIntro)) + return b.String() +} + +// Where the memories come from differs by editor, and the rules have to say +// so honestly: an instruction to rely on an injection that never happens is +// worse than an instruction to go and fetch it. +const ( + claudeIntro = `This project remembers things in ~.brain/~. A session-start hook loads the +top of it for you. These rules cover the rest.` + + cursorIntro = `This project remembers things in ~.brain/~. Nothing injects it for you with +this setup, so the first thing to do in a session that touches code is +recall.` +) + +// memoryRules is the block a user pastes into their editor's rules file. +// +// The hook only covers the top of the loop — it loads memory and stops. +// Everything after that is a judgement the model makes mid-session, which +// no hook can make for it: which domain it is touching, whether a fact is +// worth writing down, whether the work has actually shipped. +// +// The rules are written to produce restraint rather than participation. +// "Remember what you learned" yields a junk drawer, because a model asked to +// capture learnings will always find some; the useful instruction is the one +// that says what not to write and gives a test for it. 08-ai-workflow.md is +// explicit that fifty good memories beat five hundred mediocre ones, and +// that is a property of the instructions, not of the storage. +// +// Numbers come from config.Default() rather than being typed out, so the +// rules cannot drift from the thresholds the engines actually apply. +func memoryRules(intro string) string { + d := config.Default() + // Go raw strings cannot contain a backtick, and this block is mostly + // markdown code spans. Writing it with "~" and swapping at render time + // keeps the text readable for the people who will edit it, which is the + // only property that matters in a literal made of prose. + rules := strings.ReplaceAll(`## Project memory (brAIn) + +`+intro+` + +### Before changing code in an area + +Run ~brain recall --domain ~. What comes back is ranked, and the +ranking is the point: + +- **Corrections** are the owner overruling a tool. Non-negotiable. Do not + re-argue one and do not route around it. +- **Decisions** are settled. Follow them. Reopen one only when its + revisit condition has actually been met, and then write a new decision + that supersedes it rather than quietly doing something else. +- **Lessons** are patterns that already cost somebody something. Assume + they still apply. +- **Facts** go stale after %DAYS% days. Treat an old one as a claim to + check, not as ground truth. + +### Before shipping + +Run ~brain trust --domain ~ and act on the level: + +- **%ASK%** — stop and get a human to approve the change. +- **%NOTIFY%** — ship it, and say plainly that you did. +- **%AUTOSHIP%** and **%FULLAUTO%** — ship without asking. The domain earned + this by shipping cleanly, and second-guessing it wastes what it bought. + Earned autonomy is not permission to skip the recall. + +brAIn reports a level and nothing more. It does not block you, and acting on +the level is your job. + +### After the work is done + +Write down what changed the ground, not what happened: + + brain remember "" --domain --layer + +Pass ~--layer~ explicitly — ~fact~, ~lesson~, ~decision~, or ~correction~. +Left off, brAIn guesses from the wording, and a guess that files a decision +as a fact makes it expire in %DAYS% days. You know which one it is. + +Worth remembering: a codebase fact that changes how the next person +approaches this area; a lesson from a bug that took real time to find; a +decision made in this session that should outlive it. + +Not worth remembering: anything the code already says, anything true only +this week ("PR #42 is in review"), anything a competent stranger to this +project would already know, or a summary of what you just did. When in +doubt, leave it out — a missing memory costs one recall, and a junk one +costs the credibility of every memory next to it. + +### Trust outcomes are not yours to record + +~brain trust record~ belongs to the moment work actually ships: a merge, a +deploy, a production window that stayed quiet. Not the moment you stop +typing. A clean outcome recorded per finished task measures typing rather +than work surviving contact with reality, and it walks a domain up the +ladder to %AUTOSHIP% having shipped nothing. +`, "~", "`") + + return strings.NewReplacer( + "%DAYS%", strconv.Itoa(d.Facts.StaleAfterDays), + "%ASK%", string(trust.LevelAsk), + "%NOTIFY%", string(trust.LevelNotify), + "%AUTOSHIP%", string(trust.LevelAutoShip), + "%FULLAUTO%", string(trust.LevelFullAuto), + ).Replace(rules) +} + +func sessionStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "session-start", + Short: "Print project memory for an editor's session-start hook", + Long: sessionStartLongHelp, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + memories := sessionStartRecall(cmd) + if len(memories) == 0 { + return nil + } + fmt.Print(renderSessionStart(memories)) + return nil + }, + } +} + +// sessionStartRecall returns the memories to open a session with, or none. +// +// Every failure path returns nil rather than an error, because the caller is +// a hook whose only failure mode is refusing to start a session. A project +// that has never heard of brAIn, or one where init ran a minute ago, has +// nothing to say — which is not the same as something going wrong. +// +// The engine is built here rather than read from the context: hooks is in +// runsWithoutBrainDir, so it skips engine setup and engineFrom returns nil +// by the contract documented there. +func sessionStartRecall(cmd *cobra.Command) []memory.Memory { + dirFlag, err := cmd.Flags().GetString("dir") + if err != nil { + return nil + } + brainDir, err := resolveBrainDir(dirFlag) + if err != nil { + return nil + } + cfg, err := config.Load(brainDir) + if err != nil { + return nil + } + eng, err := engine.NewEngine(cmd.Context(), markdown.New(brainDir), + engine.WithLockDir(brainDir), + engine.WithFacts(cfg.Facts)) + if err != nil { + return nil + } + memories, err := eng.Recall(cmd.Context(), engine.RecallOptions{Limit: sessionStartLimit}) + if err != nil { + return nil + } + return memories +} + +// renderSessionStart frames the memories for a model rather than for a +// human: the ranking is spelled out, because a bare list does not tell a +// reader that a correction outranks a fact. +func renderSessionStart(memories []memory.Memory) string { + var b strings.Builder + b.WriteString(sessionStartHeader) + for _, m := range memories { + fmt.Fprintf(&b, "[%s] %s\n", m.Layer, m.Title()) + fmt.Fprintf(&b, " domain: %s path: %s\n\n", m.Domain, m.Path) + } + b.WriteString(sessionStartFooter) + return b.String() +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8102d9f..c3a07a5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -220,25 +220,28 @@ func registerSubcommands(root *cobra.Command) { root.AddCommand(versionCmd()) root.AddCommand(initCmd()) root.AddCommand(configCmd()) + root.AddCommand(hooksCmd()) } // runsWithoutBrainDir reports whether cmd (or any ancestor) must work when // no .brain/ directory exists yet. Walks the parent chain so "brain help // remember" and "brain completion bash" are both recognized. // -// Two kinds of command qualify: cobra's discovery commands plus brain -// version, which have to work on a fresh install; and init, which creates -// the directory the others require. +// Three kinds of command qualify: cobra's discovery commands plus brain +// version, which have to work on a fresh install; init, which creates the +// directory the others require; and hooks, which runs in whatever project +// an editor was opened in, most of which have no .brain/ at all. // // Contract: the names below are reserved. A command listed here skips engine // setup entirely, so it must never call engineFrom, trustEngineFrom, or // configFrom expecting a loaded value — all three return nil or defaults and // the first two will nil-deref. init obeys this by touching only the -// filesystem. +// filesystem; hooks obeys it by building its own engine and discarding +// every error (see sessionStartRecall). func runsWithoutBrainDir(cmd *cobra.Command) bool { for c := cmd; c != nil; c = c.Parent() { switch c.Name() { - case "help", "version", "completion", "init": + case "help", "version", "completion", "init", "hooks": return true } } From 847f20796bc7d46b0c755ad296fbec180521218c Mon Sep 17 00:00:00 2001 From: Luc Date: Fri, 18 Sep 2026 10:02:49 +0000 Subject: [PATCH 3/5] test(cli): cover brain hooks output and session-start states Pins the behaviour that would otherwise fail invisibly inside someone's editor. The scan resolving every "brain " in the printed setup and in the README against the real command tree is the load-bearing one: the definition docs already carry "brain recall --for council", a flag that has never existed. - Session start exits 0 and stays silent with no .brain/, an empty one, or a stale --dir - Authority ordering and the five-memory cap are asserted, not assumed - The settings snippet is parsed as JSON and its named command executed --- internal/cmd/hooks_test.go | 435 +++++++++++++++++++++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 internal/cmd/hooks_test.go diff --git a/internal/cmd/hooks_test.go b/internal/cmd/hooks_test.go new file mode 100644 index 0000000..645f361 --- /dev/null +++ b/internal/cmd/hooks_test.go @@ -0,0 +1,435 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/luuuc/brain/internal/config" + "github.com/luuuc/brain/internal/trust" +) + +// chdir moves to dir for the duration of the test. +func chdir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) +} + +// A session-start hook that exits non-zero blocks the session. Every state +// below is ordinary rather than exceptional — a project that has never heard +// of brAIn, one where init ran a minute ago, a stale --dir in someone's +// settings.json — and none of them is a reason to refuse to start work. +func TestSessionStart_neverFailsASession(t *testing.T) { + t.Run("no .brain/ anywhere", func(t *testing.T) { + chdir(t, t.TempDir()) + code, out := executeAndCapture(t, nil, "hooks", "session-start") + assertSilentSuccess(t, code, out) + }) + + t.Run("initialized but empty", func(t *testing.T) { + target := filepath.Join(t.TempDir(), ".brain") + if code, out := runInit(t, target); code != 0 { + t.Fatalf("init: exit %d, out=%s", code, out) + } + code, out := run(t, target, "hooks", "session-start") + assertSilentSuccess(t, code, out) + }) + + t.Run("--dir points at nothing", func(t *testing.T) { + missing := filepath.Join(t.TempDir(), "gone") + code, out := executeAndCapture(t, nil, "--dir", missing, "hooks", "session-start") + assertSilentSuccess(t, code, out) + }) +} + +func assertSilentSuccess(t *testing.T, code int, out string) { + t.Helper() + if code != 0 { + t.Errorf("exit %d, want 0 — a non-zero exit blocks the session", code) + } + if out != "" { + t.Errorf("printed context with nothing to say:\n%s", out) + } +} + +// With memories to show, the hook injects them highest-authority first and +// says so. A model reading a bare list has no way to know that a correction +// outranks a fact. +func TestSessionStart_injectsRankedMemories(t *testing.T) { + target := filepath.Join(t.TempDir(), ".brain") + if code, out := runInit(t, target); code != 0 { + t.Fatalf("init: exit %d, out=%s", code, out) + } + for _, m := range []struct{ content, domain string }{ + {"The users table has 12M rows", "database"}, + {"Stop flagging nullable email columns", "testing"}, + {"We chose camelCase for API responses", "api"}, + } { + if code, out := run(t, target, "remember", m.content, "--domain", m.domain); code != 0 { + t.Fatalf("remember %q: exit %d, out=%s", m.content, code, out) + } + } + + code, out := run(t, target, "hooks", "session-start") + if code != 0 { + t.Fatalf("exit %d, out=%s", code, out) + } + + for _, want := range []string{ + "Project memory (brAIn)", + "non-negotiable", + "Stop flagging nullable email columns", + "We chose camelCase for API responses", + "The users table has 12M rows", + "brain recall --domain", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + + // Authority order, not insertion order: the correction was remembered + // second and the fact first, but the correction has to lead. + correction := strings.Index(out, "[correction]") + decision := strings.Index(out, "[decision]") + fact := strings.Index(out, "[fact]") + if correction < 0 || decision < 0 || fact < 0 { + t.Fatalf("expected all three layers in output:\n%s", out) + } + if correction >= decision || decision >= fact { + t.Errorf("layers out of authority order (correction=%d decision=%d fact=%d):\n%s", + correction, decision, fact, out) + } +} + +// The injection budget is a standing cost: every session pays for it before +// anyone types. Five is the shaped number. +func TestSessionStart_capsWhatItInjects(t *testing.T) { + target := filepath.Join(t.TempDir(), ".brain") + if code, out := runInit(t, target); code != 0 { + t.Fatalf("init: exit %d, out=%s", code, out) + } + for i := range sessionStartLimit + 3 { + content := "Fact number " + string(rune('A'+i)) + " about the system" + if code, out := run(t, target, "remember", content, "--domain", "database"); code != 0 { + t.Fatalf("remember: exit %d, out=%s", code, out) + } + } + + code, out := run(t, target, "hooks", "session-start") + if code != 0 { + t.Fatalf("exit %d, out=%s", code, out) + } + if got := strings.Count(out, "domain: database"); got != sessionStartLimit { + t.Errorf("injected %d memories, want %d", got, sessionStartLimit) + } +} + +// Effectiveness memories reach a session like any other, and on a young +// brain they reach it first: with fewer than five higher-ranked memories +// they fill the remaining slots. Rendering one as its body's first line puts +// a bare "## Outcomes" heading in front of the model. +func TestSessionStart_rendersEffectivenessReadably(t *testing.T) { + target := filepath.Join(t.TempDir(), ".brain") + if code, out := runInit(t, target); code != 0 { + t.Fatalf("init: exit %d, out=%s", code, out) + } + if code, out := run(t, target, "track", "--domain", "payments", + "--persona", "kent-beck", "--outcome", "accepted"); code != 0 { + t.Fatalf("track: exit %d, out=%s", code, out) + } + + code, out := run(t, target, "hooks", "session-start") + if code != 0 { + t.Fatalf("exit %d, out=%s", code, out) + } + if !strings.Contains(out, "kent-beck effectiveness in payments") { + t.Errorf("effectiveness memory not identified by persona and domain:\n%s", out) + } + if strings.Contains(out, "## Outcomes") { + t.Errorf("injected a raw markdown heading as a memory title:\n%s", out) + } +} + +// settingsSnippet is the shape of the block brAIn tells people to paste. +type settingsSnippet struct { + Hooks struct { + SessionStart []struct { + Matcher string `json:"matcher"` + Hooks []struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout"` + } `json:"hooks"` + } `json:"SessionStart"` + } `json:"hooks"` +} + +// parseSnippet parses the snippet as an editor would. It gets pasted into a +// real settings.json, so malformed JSON here breaks the user's editor +// configuration and brAIn caused it. +func parseSnippet(t *testing.T) settingsSnippet { + t.Helper() + var cfg settingsSnippet + if err := json.Unmarshal([]byte(claudeSettingsSnippet), &cfg); err != nil { + t.Fatalf("snippet is not valid JSON: %v", err) + } + if len(cfg.Hooks.SessionStart) != 1 || len(cfg.Hooks.SessionStart[0].Hooks) != 1 { + t.Fatalf("want exactly one SessionStart hook, got %+v", cfg.Hooks.SessionStart) + } + return cfg +} + +func TestHooks_settingsSnippetIsWired(t *testing.T) { + h := parseSnippet(t).Hooks.SessionStart[0].Hooks[0] + if h.Type != "command" { + t.Errorf("hook type = %q, want %q", h.Type, "command") + } + // Nothing inside the hook can bound a wedged recall, so the only bound + // is this one. A snippet without it stalls session start. + if h.Timeout <= 0 { + t.Errorf("timeout = %d, want a positive bound", h.Timeout) + } +} + +// Memory has to survive the events that empty a session's context, not just +// the one that starts it. Compaction is the case that matters most: it is +// where a correction gets summarised away and the model quietly loses the +// thing it was told not to do. +func TestHooks_settingsSnippetSurvivesContextLoss(t *testing.T) { + matcher := parseSnippet(t).Hooks.SessionStart[0].Matcher + for _, want := range []string{"startup", "clear", "compact"} { + if !strings.Contains(matcher, want) { + t.Errorf("matcher %q does not cover %q", matcher, want) + } + } + // Both inherit a context that already carries the injection, so firing + // again would duplicate it. + for _, unwanted := range []string{"resume", "fork"} { + if strings.Contains(matcher, unwanted) { + t.Errorf("matcher %q re-injects into %q, which already has it", matcher, unwanted) + } + } +} + +// The snippet names a command. If that command is renamed, the hook fails +// silently inside someone's editor — so the name has to be one brAIn answers +// to, checked by running it. +func TestHooks_settingsSnippetNamesARealCommand(t *testing.T) { + fields := strings.Fields(parseSnippet(t).Hooks.SessionStart[0].Hooks[0].Command) + if len(fields) < 2 || fields[0] != "brain" { + t.Fatalf("command %q is not a brain invocation", fields) + } + // Point it at an empty store rather than letting resolveBrainDir walk up + // into this repository's own .brain/. What is asserted here is that the + // command exists and succeeds, not what a recall happens to return today. + empty := filepath.Join(t.TempDir(), ".brain") + if code, out := runInit(t, empty); code != 0 { + t.Fatalf("init: exit %d, out=%s", code, out) + } + args := append([]string{"--dir", empty}, fields[1:]...) + if code, out := executeAndCapture(t, nil, args...); code != 0 { + t.Errorf("%v: exit %d, out=%s — the snippet points at a command that fails", + fields, code, out) + } +} + +// brain hooks is a meta command: someone runs it to find out how to set +// brAIn up, which is by definition before they have a .brain/. +func TestHooks_printsSetupWithoutABrainDir(t *testing.T) { + chdir(t, t.TempDir()) + + code, out := executeAndCapture(t, nil, "hooks") + if code != 0 { + t.Fatalf("exit %d, out=%s", code, out) + } + for _, want := range []string{ + ".claude/settings.json", + claudeSettingsSnippet, + "SessionStart", + } { + if !strings.Contains(out, want) { + t.Errorf("setup output missing %q:\n%s", want, out) + } + } +} + +// findSub returns parent's immediate subcommand named name, or nil. +func findSub(parent *cobra.Command, name string) *cobra.Command { + for _, c := range parent.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +// Rules that tell a model to run a command brAIn does not have are worse +// than no rules: the model runs it, gets an error, and learns to ignore the +// section. The definition docs already carry one of these — 06-mcp-and-cli +// documents "brain recall --for council", a flag that has never existed — so +// this scans every command the printed setup names and resolves it against +// the real command tree. +// assertNamesRealCommands resolves every "brain ..." invocation in text +// against the real command tree. +func assertNamesRealCommands(t *testing.T, what, text string) { + t.Helper() + root := rootCmd() + registerSubcommands(root) + + resolved := make(map[string]bool) + for _, m := range regexp.MustCompile(`brain((?: [a-z][a-z-]*)+)`).FindAllStringSubmatch(text, -1) { + words := strings.Fields(m[1]) + cur, depth := root, 0 + for _, w := range words { + next := findSub(cur, w) + if next == nil { + break + } + cur, depth = next, depth+1 + } + if depth == 0 { + t.Errorf("%s names `brain %s`, which is not a brain command", what, words[0]) + continue + } + resolved[strings.Join(words[:depth], " ")] = true + } + + // Guard against a scan that silently matches nothing and passes. + if len(resolved) < 3 { + t.Errorf("%s: only resolved %d commands (%v); the scan is probably broken", + what, len(resolved), resolved) + } +} + +func TestHooks_namesOnlyRealCommands(t *testing.T) { + for _, tool := range []string{toolClaudeCode, toolCursor} { + code, out := executeAndCapture(t, nil, "hooks", "--tool", tool) + if code != 0 { + t.Fatalf("hooks --tool %s: exit %d, out=%s", tool, code, out) + } + assertNamesRealCommands(t, "the "+tool+" setup", out) + } +} + +// The README teaches the commands people type first, so a stale one there +// costs more than a stale one anywhere else — it is the first thing that +// fails, for someone with no way to tell whether they or the tool is wrong. +func TestREADME_namesOnlyRealCommands(t *testing.T) { + readme, err := os.ReadFile(filepath.Join("..", "..", "README.md")) + if err != nil { + t.Skipf("README not readable from here: %v", err) + } + assertNamesRealCommands(t, "README.md", string(readme)) +} + +// The numbers in the rules are the ones the engines actually apply. Typing +// them out by hand is how a template starts lying after a config change. +func TestHooks_rulesQuoteRealThresholds(t *testing.T) { + rules := memoryRules(claudeIntro) + + if want := fmt.Sprintf("%d days", config.Default().Facts.StaleAfterDays); !strings.Contains(rules, want) { + t.Errorf("rules do not state the real staleness window (%q):\n%s", want, rules) + } + for _, level := range []trust.Level{ + trust.LevelAsk, trust.LevelNotify, trust.LevelAutoShip, trust.LevelFullAuto, + } { + if !strings.Contains(rules, string(level)) { + t.Errorf("rules never mention the %q trust level", level) + } + } +} + +// Two things the rules exist to say. Both are no-gos from the pitch, and +// both are the kind of paragraph a later edit trims as negative-sounding. +// +// The restraint half is the whole value: a model told to "remember what you +// learned" will always find something, and the folder fills with things +// nobody reads. The trust half stops a domain climbing the ladder on +// finished tasks rather than on work that shipped. +func TestHooks_rulesSayWhatNotToDo(t *testing.T) { + rules := memoryRules(claudeIntro) + for _, want := range []string{ + "Not worth remembering", + "leave it out", + "Trust outcomes are not yours to record", + // Without an explicit layer, brAIn guesses from wording. The + // classifier is a substring matcher; the model is not. + "--layer", + } { + if !strings.Contains(rules, want) { + t.Errorf("rules dropped %q — see the pitch's no-gos", want) + } + } +} + +// Cursor reads the frontmatter, so a malformed header means the rule is +// silently ignored and the user believes brAIn is set up when it is not. +func TestHooks_cursorFrontmatterIsValid(t *testing.T) { + body := strings.TrimSpace(cursorFrontmatter) + body = strings.TrimPrefix(body, "---") + body = strings.TrimSuffix(body, "---") + + var fm struct { + Description string `yaml:"description"` + Globs string `yaml:"globs"` + AlwaysApply bool `yaml:"alwaysApply"` + } + if err := yaml.Unmarshal([]byte(body), &fm); err != nil { + t.Fatalf("frontmatter is not valid YAML: %v", err) + } + if fm.Description == "" { + t.Error("frontmatter has no description") + } + // Memory is not file-scoped context. Globbing it would hide a correction + // exactly when someone works somewhere nobody predicted. + if !fm.AlwaysApply { + t.Error("alwaysApply is false; the rule would only load for matching files") + } + if fm.Globs != "" { + t.Errorf("globs = %q, want empty alongside alwaysApply", fm.Globs) + } +} + +// Each editor's rules have to describe how that editor actually gets its +// memory. Telling Cursor to rely on an injection that never happens is worse +// than telling it to go and fetch it. +func TestHooks_cursorSetupDoesNotPromiseAnInjection(t *testing.T) { + code, out := executeAndCapture(t, nil, "hooks", "--tool", toolCursor) + if code != 0 { + t.Fatalf("exit %d, out=%s", code, out) + } + + for _, want := range []string{".cursor/rules/brain.mdc", "alwaysApply: true", "Not worth remembering"} { + if !strings.Contains(out, want) { + t.Errorf("cursor setup missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "session-start hook loads") { + t.Error("cursor setup claims a hook loads memory; nothing does in that setup") + } + if !strings.Contains(out, "Nothing injects it for you") { + t.Error("cursor setup does not tell the model to recall for itself") + } +} + +func TestHooks_unknownToolIsRejected(t *testing.T) { + code, _ := executeAndCapture(t, nil, "hooks", "--tool", "vim") + if code != 3 { + t.Errorf("exit %d, want 3 (invalid input)", code) + } +} From a5e93b6183f83b4094975fc72e1c6a7b3e8c5f2c Mon Sep 17 00:00:00 2001 From: Luc Date: Fri, 18 Sep 2026 10:02:56 +0000 Subject: [PATCH 4/5] docs(readme): document the editor setup path Adds an Editor Setup section covering install, brain hooks, paste, done, and updates Status now that editor integration ships. States the split a fresh clone actually gets: memories are committed and travel with the repository, while hook configuration is per-developer. --- README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b2f313..24bc161 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Not a vector database. Not a knowledge graph. Not an AI memory product. The ligh ## Status -Pre-alpha. The memory engine, CLI, MCP server, trust ladder, effectiveness tracking, and configuration are implemented. The PostgreSQL adapter and editor integrations are coming in future pitches. +Pre-alpha. The memory engine, CLI, MCP server, trust ladder, effectiveness tracking, configuration, and editor integration are implemented. The PostgreSQL adapter is coming in a future pitch. ## Install @@ -38,6 +38,34 @@ brain config # what's in effect, and where each value came from Commit `.brain/` to git so your memory travels with the project. +## Editor Setup + +Everything above is something you have to remember to run, which is the one +thing memory should not depend on. To make the loop run by itself: + +```bash +brain hooks # Claude Code: settings.json block, then CLAUDE.md rules +brain hooks --tool cursor # Cursor: .cursor/rules/brain.mdc +``` + +brAIn prints; you paste. It never edits your settings, your `CLAUDE.md`, or +anything else you own — merging into a file you have already customised is +not a memory tool's job. + +In Claude Code the hook loads your highest-authority memories into every new +session, and again after a `/clear` or a compaction. Compaction is the one +that matters most: it is where a correction gets summarised away and the +model quietly loses the thing it was told not to do. + +The rules cover what no hook can. A hook cannot know which domain you are +touching, so per-domain recall, acting on the trust level, and restraint +about what gets written down are all instructions to the model rather than +automation. + +Memories are committed, so they travel with the repository. The hook +configuration is per-developer: everyone who clones runs `brain hooks` once +on their own machine. + ## Configuration Everything works with no configuration. To change a default, uncomment the From 2943f2673cb4c9c1d20219fb7631b1c0f5638940 Mon Sep 17 00:00:00 2001 From: Luc Date: Fri, 18 Sep 2026 10:02:56 +0000 Subject: [PATCH 5/5] chore(brain): seed this repository's own memory brAIn is ten pitches deep and has never remembered anything about its own construction. Seeds sixteen memories: settled decisions that otherwise live only in gitignored files, and lessons from building that exist nowhere else. - Decisions and corrections from .doc/ and CLAUDE.md, neither of which git carries, so a session could never reach them - Lessons the work taught, including that definition-doc examples drift from the CLI --- .brain/.gitignore | 2 + .brain/brain.yml | 52 +++++++++++++++++++ .brain/corrections/.gitkeep | 0 ...r-claude-attribution-in-commit-messages.md | 7 +++ ...ubs-if-a-piece-is-unbuildable-surface-i.md | 7 +++ .brain/decisions/.gitkeep | 0 ...onfiguration-and-never-writes-it-mergin.md | 7 +++ ...tional-commit-types-with-imperative-sub.md | 7 +++ ...s-built-in-defaults-first-then-brain-ym.md | 7 +++ ...nd-decisions-live-in-doc-which-is-gitig.md | 7 +++ ...d-on-main-directly-anything-touching-co.md | 7 +++ ...arry-no-title-heading-the-display-title.md | 7 +++ ...le-static-binary-with-minimal-dependenc.md | 7 +++ ...e-default-store-postgresql-with-pgvecto.md | 7 +++ ...n-thresholds-are-settable-only-in-brain.md | 7 +++ ...aluated-only-inside-record-on-a-clean-o.md | 7 +++ .brain/effectiveness/.gitkeep | 0 .brain/facts/.gitkeep | 0 .brain/lessons/.gitkeep | 0 ...-that-exits-non-zero-blocks-the-session.md | 7 +++ ...es-drift-from-the-cli-06-mcp-and-cli-do.md | 7 +++ ...-directories-an-empty-layer-directory-v.md | 7 +++ ...ad-like-corrections-but-behave-like-les.md | 7 +++ .brain/trust/.gitkeep | 0 24 files changed, 166 insertions(+) create mode 100644 .brain/.gitignore create mode 100644 .brain/brain.yml create mode 100644 .brain/corrections/.gitkeep create mode 100644 .brain/corrections/2026-09-18-no-ai-or-claude-attribution-in-commit-messages.md create mode 100644 .brain/corrections/2026-09-18-no-placeholders-or-stubs-if-a-piece-is-unbuildable-surface-i.md create mode 100644 .brain/decisions/.gitkeep create mode 100644 .brain/decisions/brain-prints-editor-configuration-and-never-writes-it-mergin.md create mode 100644 .brain/decisions/commits-follow-conventional-commit-types-with-imperative-sub.md create mode 100644 .brain/decisions/configuration-resolves-built-in-defaults-first-then-brain-ym.md create mode 100644 .brain/decisions/design-docs-pitches-and-decisions-live-in-doc-which-is-gitig.md create mode 100644 .brain/decisions/docs-only-changes-land-on-main-directly-anything-touching-co.md create mode 100644 .brain/decisions/effectiveness-files-carry-no-title-heading-the-display-title.md create mode 100644 .brain/decisions/go-compiled-to-a-single-static-binary-with-minimal-dependenc.md create mode 100644 .brain/decisions/markdown-files-are-the-default-store-postgresql-with-pgvecto.md create mode 100644 .brain/decisions/trust-fact-and-lesson-thresholds-are-settable-only-in-brain.md create mode 100644 .brain/decisions/trust-promotion-is-evaluated-only-inside-record-on-a-clean-o.md create mode 100644 .brain/effectiveness/.gitkeep create mode 100644 .brain/facts/.gitkeep create mode 100644 .brain/lessons/.gitkeep create mode 100644 .brain/lessons/a-session-start-hook-that-exits-non-zero-blocks-the-session.md create mode 100644 .brain/lessons/definition-doc-examples-drift-from-the-cli-06-mcp-and-cli-do.md create mode 100644 .brain/lessons/git-carries-files-not-directories-an-empty-layer-directory-v.md create mode 100644 .brain/lessons/prescriptive-rules-read-like-corrections-but-behave-like-les.md create mode 100644 .brain/trust/.gitkeep diff --git a/.brain/.gitignore b/.brain/.gitignore new file mode 100644 index 0000000..b35c951 --- /dev/null +++ b/.brain/.gitignore @@ -0,0 +1,2 @@ +# Machine-local lock files, recreated on demand. +*.lock diff --git a/.brain/brain.yml b/.brain/brain.yml new file mode 100644 index 0000000..d4bf92e --- /dev/null +++ b/.brain/brain.yml @@ -0,0 +1,52 @@ +# brAIn configuration +# +# Every setting below is shown at its built-in default and commented out. +# Uncomment a line to override it. Deleting this file is safe — brAIn runs +# on the defaults with no config at all. +# +# Precedence, lowest to highest: built-in defaults, this file, environment +# variables. Run "brain config" to see where each value is coming from. + +# Storage backend. Only "markdown" is available today; "pg" is planned. +# Override with $BRAIN_STORAGE. +# storage: markdown + +# PostgreSQL connection, used only when storage is "pg". +# Override with $BRAIN_DATABASE_URL. +# database: +# url: postgres://brain:secret@localhost:5432/brain_dev + +# Embedding model for semantic recall, used only when storage is "pg". +# Providers: openai, anthropic, ollama. +# Override with $BRAIN_EMBEDDING_PROVIDER and $BRAIN_EMBEDDING_MODEL. +# embeddings: +# provider: openai +# model: text-embedding-3-small + +# Trust ladder: clean outcomes needed to climb each rung. The counter resets +# to zero at every promotion, so these are per-level, not cumulative. +# +# These gate the edges BETWEEN levels, not the levels themselves. Raising a +# threshold does not demote a domain that is already past it — a domain at +# auto_ship stays there no matter what you set promote_to_notify to. Use +# "brain trust override" to move a domain down deliberately. +# trust: +# promote_to_notify: 10 +# promote_to_auto_ship: 30 +# promote_to_full_auto: 100 +# # Total to reach full_auto: 140 + +# How long a fact stays fresh. Stamped onto each fact when it is written, +# so changing this affects new facts only — facts already on disk keep the +# expiry they were created with. +# facts: +# stale_after_days: 30 + +# Clean-outcome streak at which a lesson retires. A lesson can override +# this with its own retire_after frontmatter field. +# +# Retirement is permanent. Raising this does nothing for lessons the old, +# lower value already retired. Lowering it can retire several lessons at once +# on the next clean outcome. +# lessons: +# retire_after_streak: 20 diff --git a/.brain/corrections/.gitkeep b/.brain/corrections/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.brain/corrections/2026-09-18-no-ai-or-claude-attribution-in-commit-messages.md b/.brain/corrections/2026-09-18-no-ai-or-claude-attribution-in-commit-messages.md new file mode 100644 index 0000000..f1ba46f --- /dev/null +++ b/.brain/corrections/2026-09-18-no-ai-or-claude-attribution-in-commit-messages.md @@ -0,0 +1,7 @@ +--- +layer: correction +domain: workflow +created: 2026-09-18T08:16:52.051511Z +source: human +--- +No AI or Claude attribution in commit messages \ No newline at end of file diff --git a/.brain/corrections/2026-09-18-no-placeholders-or-stubs-if-a-piece-is-unbuildable-surface-i.md b/.brain/corrections/2026-09-18-no-placeholders-or-stubs-if-a-piece-is-unbuildable-surface-i.md new file mode 100644 index 0000000..75a54fe --- /dev/null +++ b/.brain/corrections/2026-09-18-no-placeholders-or-stubs-if-a-piece-is-unbuildable-surface-i.md @@ -0,0 +1,7 @@ +--- +layer: correction +domain: workflow +created: 2026-09-18T08:16:52.054917Z +source: human +--- +No placeholders or stubs — if a piece is unbuildable, surface it in the pitch scope rather than faking it \ No newline at end of file diff --git a/.brain/decisions/.gitkeep b/.brain/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.brain/decisions/brain-prints-editor-configuration-and-never-writes-it-mergin.md b/.brain/decisions/brain-prints-editor-configuration-and-never-writes-it-mergin.md new file mode 100644 index 0000000..4233ef0 --- /dev/null +++ b/.brain/decisions/brain-prints-editor-configuration-and-never-writes-it-mergin.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: cli +created: 2026-09-18T08:16:43.927566Z +source: human +--- +brAIn prints editor configuration and never writes it; merging into a settings file the user owns is not a memory tool's job \ No newline at end of file diff --git a/.brain/decisions/commits-follow-conventional-commit-types-with-imperative-sub.md b/.brain/decisions/commits-follow-conventional-commit-types-with-imperative-sub.md new file mode 100644 index 0000000..b2b1a02 --- /dev/null +++ b/.brain/decisions/commits-follow-conventional-commit-types-with-imperative-sub.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: workflow +created: 2026-09-18T08:16:43.917673Z +source: human +--- +Commits follow conventional commit types with imperative subjects of 72 characters or less \ No newline at end of file diff --git a/.brain/decisions/configuration-resolves-built-in-defaults-first-then-brain-ym.md b/.brain/decisions/configuration-resolves-built-in-defaults-first-then-brain-ym.md new file mode 100644 index 0000000..0dc4e80 --- /dev/null +++ b/.brain/decisions/configuration-resolves-built-in-defaults-first-then-brain-ym.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: config +created: 2026-09-18T08:16:43.910867Z +source: human +--- +Configuration resolves built-in defaults first, then brain.yml, then BRAIN_* environment variables \ No newline at end of file diff --git a/.brain/decisions/design-docs-pitches-and-decisions-live-in-doc-which-is-gitig.md b/.brain/decisions/design-docs-pitches-and-decisions-live-in-doc-which-is-gitig.md new file mode 100644 index 0000000..314c3ec --- /dev/null +++ b/.brain/decisions/design-docs-pitches-and-decisions-live-in-doc-which-is-gitig.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: workflow +created: 2026-09-18T08:16:43.914258Z +source: human +--- +Design docs, pitches, and decisions live in .doc/, which is gitignored and stays out of the public repo \ No newline at end of file diff --git a/.brain/decisions/docs-only-changes-land-on-main-directly-anything-touching-co.md b/.brain/decisions/docs-only-changes-land-on-main-directly-anything-touching-co.md new file mode 100644 index 0000000..0338386 --- /dev/null +++ b/.brain/decisions/docs-only-changes-land-on-main-directly-anything-touching-co.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: workflow +created: 2026-09-18T08:16:43.920987Z +source: human +--- +Docs-only changes land on main directly; anything touching code goes through a branch and a PR \ No newline at end of file diff --git a/.brain/decisions/effectiveness-files-carry-no-title-heading-the-display-title.md b/.brain/decisions/effectiveness-files-carry-no-title-heading-the-display-title.md new file mode 100644 index 0000000..fff159c --- /dev/null +++ b/.brain/decisions/effectiveness-files-carry-no-title-heading-the-display-title.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: storage +created: 2026-09-18T08:16:43.93096Z +source: human +--- +Effectiveness files carry no title heading — the display title is derived from persona and domain so there is only one copy of it \ No newline at end of file diff --git a/.brain/decisions/go-compiled-to-a-single-static-binary-with-minimal-dependenc.md b/.brain/decisions/go-compiled-to-a-single-static-binary-with-minimal-dependenc.md new file mode 100644 index 0000000..dfce296 --- /dev/null +++ b/.brain/decisions/go-compiled-to-a-single-static-binary-with-minimal-dependenc.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: architecture +created: 2026-09-18T08:16:43.899489Z +source: human +--- +Go, compiled to a single static binary with minimal dependencies, so installing brAIn is downloading one file \ No newline at end of file diff --git a/.brain/decisions/markdown-files-are-the-default-store-postgresql-with-pgvecto.md b/.brain/decisions/markdown-files-are-the-default-store-postgresql-with-pgvecto.md new file mode 100644 index 0000000..eea9721 --- /dev/null +++ b/.brain/decisions/markdown-files-are-the-default-store-postgresql-with-pgvecto.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: storage +created: 2026-09-18T08:16:43.903653Z +source: human +--- +Markdown files are the default store; PostgreSQL with pgvector is an optional upgrade and never a requirement \ No newline at end of file diff --git a/.brain/decisions/trust-fact-and-lesson-thresholds-are-settable-only-in-brain.md b/.brain/decisions/trust-fact-and-lesson-thresholds-are-settable-only-in-brain.md new file mode 100644 index 0000000..6d0e87d --- /dev/null +++ b/.brain/decisions/trust-fact-and-lesson-thresholds-are-settable-only-in-brain.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: config +created: 2026-09-18T08:16:43.907156Z +source: human +--- +Trust, fact, and lesson thresholds are settable only in brain.yml, never by environment variable — a threshold set by an invisible variable is a trust ladder nobody can audit \ No newline at end of file diff --git a/.brain/decisions/trust-promotion-is-evaluated-only-inside-record-on-a-clean-o.md b/.brain/decisions/trust-promotion-is-evaluated-only-inside-record-on-a-clean-o.md new file mode 100644 index 0000000..6eed086 --- /dev/null +++ b/.brain/decisions/trust-promotion-is-evaluated-only-inside-record-on-a-clean-o.md @@ -0,0 +1,7 @@ +--- +layer: decision +domain: trust +created: 2026-09-18T08:16:43.924238Z +source: human +--- +Trust promotion is evaluated only inside Record on a clean outcome — loading trust state never promotes, so reads never become locked writes \ No newline at end of file diff --git a/.brain/effectiveness/.gitkeep b/.brain/effectiveness/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.brain/facts/.gitkeep b/.brain/facts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.brain/lessons/.gitkeep b/.brain/lessons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.brain/lessons/a-session-start-hook-that-exits-non-zero-blocks-the-session.md b/.brain/lessons/a-session-start-hook-that-exits-non-zero-blocks-the-session.md new file mode 100644 index 0000000..146505c --- /dev/null +++ b/.brain/lessons/a-session-start-hook-that-exits-non-zero-blocks-the-session.md @@ -0,0 +1,7 @@ +--- +layer: lesson +domain: cli +created: 2026-09-18T08:16:52.036925Z +source: human +--- +A session-start hook that exits non-zero blocks the session, and brain recall exits 2 on an empty store — every hook consumer must absorb both \ No newline at end of file diff --git a/.brain/lessons/definition-doc-examples-drift-from-the-cli-06-mcp-and-cli-do.md b/.brain/lessons/definition-doc-examples-drift-from-the-cli-06-mcp-and-cli-do.md new file mode 100644 index 0000000..62dd395 --- /dev/null +++ b/.brain/lessons/definition-doc-examples-drift-from-the-cli-06-mcp-and-cli-do.md @@ -0,0 +1,7 @@ +--- +layer: lesson +domain: docs +created: 2026-09-18T08:16:52.040788Z +source: human +--- +Definition-doc examples drift from the CLI: 06-mcp-and-cli documents brain recall --for council, a flag that never existed. Verify every command in a template against the real command tree \ No newline at end of file diff --git a/.brain/lessons/git-carries-files-not-directories-an-empty-layer-directory-v.md b/.brain/lessons/git-carries-files-not-directories-an-empty-layer-directory-v.md new file mode 100644 index 0000000..c282d18 --- /dev/null +++ b/.brain/lessons/git-carries-files-not-directories-an-empty-layer-directory-v.md @@ -0,0 +1,7 @@ +--- +layer: lesson +domain: cli +created: 2026-09-18T08:16:52.048041Z +source: human +--- +git carries files, not directories: an empty layer directory vanishes from a clone unless something occupies it \ No newline at end of file diff --git a/.brain/lessons/prescriptive-rules-read-like-corrections-but-behave-like-les.md b/.brain/lessons/prescriptive-rules-read-like-corrections-but-behave-like-les.md new file mode 100644 index 0000000..88e8367 --- /dev/null +++ b/.brain/lessons/prescriptive-rules-read-like-corrections-but-behave-like-les.md @@ -0,0 +1,7 @@ +--- +layer: lesson +domain: engine +created: 2026-09-18T08:16:52.044478Z +source: human +--- +Prescriptive rules read like corrections but behave like lessons — filing one as a correction makes it permanent and impossible to retire \ No newline at end of file diff --git a/.brain/trust/.gitkeep b/.brain/trust/.gitkeep new file mode 100644 index 0000000..e69de29