feat: generate brain tools skill from OpenAPI; rename db-yaml to postgres (#21)
Tests / Test (push) Failing after 5s
Tests / Release (semver) (push) Skipped

Cursor skills stay in lockstep with serve handlers. CI checks every bin/
path named in SKILL.md exists.
This commit is contained in:
2026-08-13 21:32:57 +01:00
committed by GitHub
co-authored by GitHub
parent 77c635b16b
commit 6b680bdb90
10 changed files with 127 additions and 43 deletions
+2 -2
View File
@@ -51,7 +51,7 @@ detective method: **a fact needs ≥2 independent sources or it is
2dph/ 2dph/
PLAN.md / AGENTS.md PLAN.md / AGENTS.md
docs/ published docs (this conversation → docs/ as md) docs/ published docs (this conversation → docs/ as md)
skills/ in-project skills (web-search, db-yaml, brain, diataxis-docs) skills/ in-project skills (web-search, postgres, brain, diataxis-docs)
bin/ bin/
facts/extract.go audit.go crm.go # D14 shebang; Python implementation facts/extract.go audit.go crm.go # D14 shebang; Python implementation
kb/index Python write path (called by bin/brain/index.go) kb/index Python write path (called by bin/brain/index.go)
@@ -146,7 +146,7 @@ Feedback loop: every commit → PR → CI → green/gate → merge. Same discipl
1. scaffold repo (:done after this file + AGENTS.md + .gitignore + ci) 1. scaffold repo (:done after this file + AGENTS.md + .gitignore + ci)
2. gh repo create eSlider/2dph --private + initial commit + CI 2. gh repo create eSlider/2dph --private + initial commit + CI
3. vendored skill integration (web-search, db-yaml, brain, diataxis-docs) — no remote links 3. vendored skill integration (web-search, postgres, brain, diataxis-docs) — no remote links
4. .venv: ladybug + model2vec + mistune 4. .venv: ladybug + model2vec + mistune
5. schema + tools with TDD (kb + md + facts + brain) 5. schema + tools with TDD (kb + md + facts + brain)
6. ~/.config/brain config 6. ~/.config/brain config
+1 -1
View File
@@ -166,7 +166,7 @@ docker compose up brain-watch # auto re-index on change
- [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier - [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier
Neo4j + Qdrant + Matrix RAG brain Neo4j + Qdrant + Matrix RAG brain
- [agent-skills](https://github.com/eSlider/agent-skills) — upstream - [agent-skills](https://github.com/eSlider/agent-skills) — upstream
skills (`web-search`, `db-yaml`, …) that 2dph integrates skills (`web-search`, `postgres`, …) that 2dph integrates
- detective method — the two-source method - detective method — the two-source method
Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues). Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues).
+2
View File
@@ -74,6 +74,8 @@ class PublishedDocsTest(unittest.TestCase):
self.assertIn("/mcp", (ROOT / "README.md").read_text()) self.assertIn("/mcp", (ROOT / "README.md").read_text())
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text() skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
self.assertIn("/mcp", skill) self.assertIn("/mcp", skill)
self.assertFalse((ROOT / "skills" / "db-yaml").exists())
self.assertTrue((ROOT / "skills" / "postgres" / "SKILL.md").is_file())
def test_readme_search_escalates_web(self) -> None: def test_readme_search_escalates_web(self) -> None:
text = (ROOT / "README.md").read_text() text = (ROOT / "README.md").read_text()
+39
View File
@@ -0,0 +1,39 @@
"""Skills must name live commands; every bin/ path in SKILL.md must exist."""
from __future__ import annotations
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
BIN_PATH = re.compile(r"(bin/[A-Za-z0-9_./-]+)")
class SkillsTest(unittest.TestCase):
def test_db_yaml_renamed_to_postgres(self) -> None:
self.assertFalse(
(ROOT / "skills" / "db-yaml").exists(),
"skills/db-yaml must be skills/postgres",
)
self.assertTrue((ROOT / "skills" / "postgres" / "SKILL.md").is_file())
text = (ROOT / "skills" / "postgres" / "SKILL.md").read_text()
self.assertIn("bin/postgres/query.go", text)
self.assertNotIn("search.ops.io", text)
def test_every_bin_path_in_skills_exists(self) -> None:
missing: list[str] = []
for path in (ROOT / "skills").rglob("SKILL.md"):
text = path.read_text()
for m in BIN_PATH.finditer(text):
rel = m.group(1).rstrip(")`.,;")
candidate = ROOT / rel
if not candidate.exists():
missing.append(f"{path.relative_to(ROOT)}: {rel}")
self.assertEqual(missing, [], "skill bin paths must exist")
def test_brain_skill_lists_generated_tools(self) -> None:
tools = (ROOT / "skills" / "brain" / "tools.md").read_text()
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
self.assertIn("tools.md", skill)
for name in ("search", "get", "stats", "audit"):
self.assertIn(f"`{name}`", tools)
+18
View File
@@ -1,5 +1,7 @@
package httpapi package httpapi
import "strings"
// Shared HTTP surface: OpenAPI paths and MCP tools are generated from Ops. // Shared HTTP surface: OpenAPI paths and MCP tools are generated from Ops.
// ServeHTTP must keep the same path strings. // ServeHTTP must keep the same path strings.
@@ -124,3 +126,19 @@ func MCPTools() []MCPTool {
} }
return out return out
} }
// SkillMarkdown is the Cursor skill fragment generated from Ops/MCPTools.
func SkillMarkdown() string {
var b strings.Builder
b.WriteString("# brain HTTP / MCP tools\n\n")
b.WriteString("Generated from `internal/httpapi.Ops`. Do not edit by hand.\n\n")
b.WriteString("Serve: `bin/brain/serve.go` (`GET /openapi.json`, `POST /mcp`).\n\n")
for _, t := range MCPTools() {
b.WriteString("- `")
b.WriteString(t.Name)
b.WriteString("` — ")
b.WriteString(t.Description)
b.WriteString("\n")
}
return b.String()
}
+13
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -76,6 +78,17 @@ func TestMCPToolsListAndCall(t *testing.T) {
} }
} }
func TestSkillMarkdownMatchesCommittedFile(t *testing.T) {
want, err := os.ReadFile(filepath.Join("..", "..", "skills", "brain", "tools.md"))
if err != nil {
t.Fatal(err)
}
got := SkillMarkdown()
if got != string(want) {
t.Fatalf("skills/brain/tools.md stale; regenerate from SkillMarkdown()\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func postJSON(t *testing.T, h http.Handler, path, raw string) (int, []byte) { func postJSON(t *testing.T, h http.Handler, path, raw string) (int, []byte) {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(raw)) req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(raw))
+2 -1
View File
@@ -45,5 +45,6 @@ are not wired yet); do not treat it as a graph walk.
- If recall looks wrong, run `bin/brain/eval.go`; it gates control questions and - If recall looks wrong, run `bin/brain/eval.go`; it gates control questions and
should stay at or above 95% recall@5. should stay at or above 95% recall@5.
- Agents: `GET /openapi.json` and `POST /mcp` on `bin/brain/serve.go` (same - Agents: `GET /openapi.json` and `POST /mcp` on `bin/brain/serve.go` (same
handlers; tool names match paths `search`/`get`/`stats`/`audit`). handlers; tool names match paths `search`/`get`/`stats`/`audit`). Generated
list: [tools.md](tools.md).
- Never report an unconfirmed single-source local answer as fact. - Never report an unconfirmed single-source local answer as fact.
+11
View File
@@ -0,0 +1,11 @@
# brain HTTP / MCP tools
Generated from `internal/httpapi.Ops`. Do not edit by hand.
Serve: `bin/brain/serve.go` (`GET /openapi.json`, `POST /mcp`).
- `search` — deduction search (facts → info → web)
- `get` — read one leaf by id
- `stats` — index health
- `audit` — facts confidence histogram
- `ingest` — rebuild hint (write is v2)
-39
View File
@@ -1,39 +0,0 @@
---
name: db-yaml
description: >-
Read any Postgres as compact YAML through db/psql-yq, with a read-only guard and
named profiles. Use when a task needs table contents, column types or a SELECT
against cs_brain or another project database.
---
# db-yaml
`bin/db/psql-yq` (vendored in this repo) talks to Postgres and returns YAML,
which is far cheaper than a psql ASCII table and easy to slice with `yq`.
```bash
bin/db/psql-yq --profile onlyoffice -s document_asset # column list
bin/db/psql-yq --profile onlyoffice -t task_result -l 20 # sample rows as YAML
bin/db/psql-yq --profile onlyoffice -c 'SELECT ...' # query -> YAML
```
Ad-hoc targets without a profile:
```bash
bin/db/psql-yq --container my-pg --db app -c 'SELECT 1'
bin/db/psql-yq --dsn 'postgres://user@host:5432/db' -c 'SELECT 1'
```
## Profiles
Connection details live in `~/.config/brain/db-profiles.yml` (mode 600), never in a
project repo. A profile names either a `container` or a `host`; passwords are read
from a separate `password_env_file` and never appear in argv.
## Rules
- **Read-only.** Any `insert|update|delete|drop|truncate|alter|create|grant|
revoke|vacuum|copy` is rejected with exit 3. Do not work around it.
- **PII.** `cs_brain` holds client data. Aggregate and count freely; never copy
names or addresses into chat, issues or docs.
- Use `-l` to keep samples small. Twenty rows answer most questions.
+39
View File
@@ -0,0 +1,39 @@
---
name: postgres
description: >-
Read Postgres as compact YAML through bin/postgres/query.go (read-only
guard, named profiles). Use when a task needs table contents, column types,
or a SELECT against an ops database.
---
# postgres
`bin/postgres/query.go` wraps vendored `bin/db/psql-yq`. Output is YAML
(cheaper than psql ASCII, easy to slice with `yq`).
```bash
bin/postgres/query.go --profile onlyoffice -s document_asset # column list
bin/postgres/query.go --profile onlyoffice -t task_result -l 20 # sample rows
bin/postgres/query.go --profile onlyoffice -c 'SELECT ...' # query → YAML
```
Ad-hoc targets without a profile:
```bash
bin/postgres/query.go --container my-pg --db app -c 'SELECT 1'
bin/postgres/query.go --dsn 'postgres://user@host:5432/db' -c 'SELECT 1'
```
## Profiles
Connection details live in `$HOME/.config/brain/db-profiles.yml` (mode 600),
never in a project repo. A profile names either a `container` or a `host`;
passwords are read from a separate `password_env_file` and never appear in argv.
## Rules
- **Read-only.** Any `insert|update|delete|drop|truncate|alter|create|grant|
revoke|vacuum|copy` is rejected with exit 3. Do not work around it.
- **PII.** Client CRM databases: aggregate and count freely; never copy names
or addresses into chat, issues or docs.
- Use `-l` to keep samples small. Twenty rows answer most questions.