Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9173f9058d | ||
|
|
3e84a41fed | ||
|
|
0e0571c7da | ||
|
|
7a27cf71a2 | ||
|
|
63df6d3283 | ||
|
|
f04b03e07e | ||
|
|
6c61f93146 | ||
|
|
95a69fc2c1 | ||
|
|
bb8bc0478c | ||
|
|
a961a3c064 |
@@ -1,315 +0,0 @@
|
|||||||
# Agent context — Mathias workspace
|
|
||||||
|
|
||||||
<!-- Canonical root context for all AI coding agents.
|
|
||||||
Lives at: ~/dev/.context/AGENT.md
|
|
||||||
Applies to every project under ~/dev/ unless overridden.
|
|
||||||
|
|
||||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
|
||||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
|
||||||
|
|
||||||
## Who I am
|
|
||||||
|
|
||||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
|
||||||
I build software, research emerging tech, and deliver consulting engagements
|
|
||||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
|
||||||
and climate/sustainability tech.
|
|
||||||
|
|
||||||
## How I work with agents
|
|
||||||
|
|
||||||
- I think like a product manager — I care about *why* before *how*
|
|
||||||
- I want agents to be opinionated and push back, not just execute blindly
|
|
||||||
- I prefer concise responses; skip ceremony and get to the point
|
|
||||||
- When I say "build this", I mean production-quality with tests, not a demo
|
|
||||||
- Ask me before making irreversible changes or adding heavy dependencies
|
|
||||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
|
||||||
|
|
||||||
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
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
|
||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
|
||||||
deployable. Never create long-lived feature branches.
|
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
|
||||||
session. Do not leave agent branches open between sessions.
|
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
|
||||||
the reason in PROJECT.md.
|
|
||||||
|
|
||||||
## Default stack
|
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
|
||||||
|-------|---------|----------|-------------|
|
|
||||||
| Language | Go | Python | TypeScript, Java, C |
|
|
||||||
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
|
||||||
| Build | Task (taskfile.dev) | Make | — |
|
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
|
||||||
| Logging | slog (structured) | — | — |
|
|
||||||
| Testing | Table-driven, testify | — | — |
|
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
|
||||||
|
|
||||||
## Code conventions
|
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
|
||||||
one logical change per commit, CI is the quality gate
|
|
||||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
|
||||||
passing `task check` locally first
|
|
||||||
- **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
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
Three machines on Tailscale:
|
|
||||||
|
|
||||||
| Machine | Role | Key specs |
|
|
||||||
|---------|------|-----------|
|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
|
||||||
- **Networking**: Tailscale mesh
|
|
||||||
|
|
||||||
## Project landscape
|
|
||||||
|
|
||||||
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
|
||||||
|
|
||||||
Organized in thematic folders:
|
|
||||||
|
|
||||||
| Folder | Focus | Count |
|
|
||||||
|--------|-------|-------|
|
|
||||||
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
|
||||||
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
|
||||||
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
|
||||||
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
|
||||||
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
|
||||||
|
|
||||||
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|
||||||
|
|
||||||
### Key active projects
|
|
||||||
|
|
||||||
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
|
||||||
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
|
||||||
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
|
||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
|
||||||
|
|
||||||
## Knowledge base — actively use it
|
|
||||||
|
|
||||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
|
||||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
|
||||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
|
||||||
reference material — query it actively, not just when explicitly told.**
|
|
||||||
|
|
||||||
### When to query (treat as a reflex)
|
|
||||||
|
|
||||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
|
||||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
|
||||||
- **When debugging** — search for the error string, the stack frame, the affected
|
|
||||||
service. Past you may have already paid this tax.
|
|
||||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
|
||||||
was tried and rejected, or what the integration footguns are.
|
|
||||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
|
||||||
"decision" to find prior reasoning before re-deriving it.
|
|
||||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
|
||||||
documented?" The brain often has it.
|
|
||||||
|
|
||||||
### When to write
|
|
||||||
|
|
||||||
After you discover something that **future-you would forget** and that **isn't
|
|
||||||
recoverable from the code, git log, or PR description alone**:
|
|
||||||
|
|
||||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
|
||||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
|
||||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
|
||||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
|
||||||
|
|
||||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
|
||||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
|
||||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|
||||||
|
|
||||||
### How to access (per harness)
|
|
||||||
|
|
||||||
| Harness | Query | Write |
|
|
||||||
|---------|-------|-------|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
|
||||||
berget URL (see knowledge entry on namespace mismatches).
|
|
||||||
|
|
||||||
### Quick reflex checks
|
|
||||||
|
|
||||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
|
||||||
|
|
||||||
- "I think the issue might be..."
|
|
||||||
- "Let me try X and see..."
|
|
||||||
- "I'll just write a script to..."
|
|
||||||
- "This is probably a new bug..."
|
|
||||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
|
||||||
|
|
||||||
## Client work rules
|
|
||||||
|
|
||||||
When working on a project tagged with a client name:
|
|
||||||
1. Never send code, data, or context to cloud APIs — use local models only
|
|
||||||
2. Never reference other client projects or their data
|
|
||||||
3. Keep all artifacts within the client's git org / directory
|
|
||||||
4. Treat everything as confidential unless told otherwise
|
|
||||||
|
|
||||||
## Harness-agnostic principles
|
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
|
||||||
|
|
||||||
## How context propagates
|
|
||||||
|
|
||||||
Canonical sources of truth:
|
|
||||||
- Universal: `~/dev/.context/AGENT.md` (this file)
|
|
||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
|
||||||
Workflow:
|
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
|
||||||
derived together. Push.
|
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
|
||||||
message telling you to stage the regenerated files.
|
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
|
||||||
unconditionally on every host, every harness.
|
|
||||||
|
|
||||||
## Engineering Skills
|
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project context
|
|
||||||
|
|
||||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
|
||||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
|
||||||
prepended for harnesses that don't walk the directory tree. -->
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
|
|
||||||
- **Name**: supervisor
|
|
||||||
- **Owner**: Mathias
|
|
||||||
- **Client**: personal
|
|
||||||
- **Repo**:
|
|
||||||
- **Status**: active
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Primary language**: Go
|
|
||||||
- **UI layer**: HTMX + Templ (when applicable)
|
|
||||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
|
||||||
- **Build**: Task (taskfile.dev), not Make
|
|
||||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
|
||||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
### Code style
|
|
||||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
|
||||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
|
||||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
|
||||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
|
||||||
|
|
||||||
### Architecture preferences
|
|
||||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
|
||||||
- Dependency injection via constructor functions, not containers
|
|
||||||
- Configuration via environment variables, parsed at startup into a typed struct
|
|
||||||
- Structured logging via `slog`
|
|
||||||
|
|
||||||
### Git
|
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
|
||||||
- Client data never leaves local network unless explicitly cleared
|
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
|
||||||
|
|
||||||
## MCP endpoints
|
|
||||||
|
|
||||||
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
|
||||||
|
|
||||||
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
|
||||||
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
|
||||||
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
|
||||||
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
|
||||||
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
|
||||||
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
|
||||||
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
|
||||||
(opt-in). Only `mode client-local` registers this endpoint.
|
|
||||||
|
|
||||||
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
|
||||||
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
|
||||||
the routing pod; brain tools moved to the brain MCP.
|
|
||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
|
||||||
for shell scripts and non-MCP clients.
|
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
|
||||||
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
|
||||||
|
|
||||||
## Agent instructions
|
|
||||||
|
|
||||||
When acting as a coding agent on this project:
|
|
||||||
|
|
||||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
|
||||||
2. Run `task check` before committing (lint + test + vet)
|
|
||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
|
||||||
4. Never modify files outside the project root without explicit permission
|
|
||||||
5. When adding a dependency, explain why in the commit message
|
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
@@ -32,6 +32,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
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
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
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
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| 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
|
- **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
|
- **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 -- <cmd>`. 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
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
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 |
|
| **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 |
|
| **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":"..."}` |
|
| **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`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **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
|
## 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:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
-318
@@ -1,318 +0,0 @@
|
|||||||
# Cursor rules — auto-generated
|
|
||||||
# Do not edit. Run: task context:sync
|
|
||||||
|
|
||||||
# Agent context — Mathias workspace
|
|
||||||
|
|
||||||
<!-- Canonical root context for all AI coding agents.
|
|
||||||
Lives at: ~/dev/.context/AGENT.md
|
|
||||||
Applies to every project under ~/dev/ unless overridden.
|
|
||||||
|
|
||||||
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
|
|
||||||
Project-level context in .context/PROJECT.md layers on top of this. -->
|
|
||||||
|
|
||||||
## Who I am
|
|
||||||
|
|
||||||
I'm Mathias, a digital product manager and technology consultant based in Sweden.
|
|
||||||
I build software, research emerging tech, and deliver consulting engagements
|
|
||||||
for clients under NDA. I work across AI/ML, financial automation, web applications,
|
|
||||||
and climate/sustainability tech.
|
|
||||||
|
|
||||||
## How I work with agents
|
|
||||||
|
|
||||||
- I think like a product manager — I care about *why* before *how*
|
|
||||||
- I want agents to be opinionated and push back, not just execute blindly
|
|
||||||
- I prefer concise responses; skip ceremony and get to the point
|
|
||||||
- When I say "build this", I mean production-quality with tests, not a demo
|
|
||||||
- Ask me before making irreversible changes or adding heavy dependencies
|
|
||||||
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
|
|
||||||
|
|
||||||
## Behavior rules
|
|
||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
|
||||||
|
|
||||||
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
|
|
||||||
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
|
|
||||||
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
|
|
||||||
files, and formatting alone. Diffs should be small and reviewable.
|
|
||||||
4. **Goal-driven execution.** Define clear success criteria up front for every task.
|
|
||||||
Loop — implement, verify, refine — until those criteria are met. Don't claim
|
|
||||||
completion without evidence (tests pass, command output, observed behavior).
|
|
||||||
5. **Trunk-Based Development — commit directly to main.** Every commit is one
|
|
||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
|
||||||
deployable. Never create long-lived feature branches.
|
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
|
||||||
session. Do not leave agent branches open between sessions.
|
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
|
||||||
the reason in PROJECT.md.
|
|
||||||
|
|
||||||
## Default stack
|
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
|
||||||
|-------|---------|----------|-------------|
|
|
||||||
| Language | Go | Python | TypeScript, Java, C |
|
|
||||||
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
|
|
||||||
| Build | Task (taskfile.dev) | Make | — |
|
|
||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
|
||||||
| Logging | slog (structured) | — | — |
|
|
||||||
| Testing | Table-driven, testify | — | — |
|
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
|
||||||
|
|
||||||
Exploratory: Rust, Zig — I'll tell you when I want these.
|
|
||||||
|
|
||||||
## Code conventions
|
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
|
||||||
one logical change per commit, CI is the quality gate
|
|
||||||
- **Never**: long-lived feature branches, PRs for solo work, direct push without
|
|
||||||
passing `task check` locally first
|
|
||||||
- **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
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
Three machines on Tailscale:
|
|
||||||
|
|
||||||
| Machine | Role | Key specs |
|
|
||||||
|---------|------|-----------|
|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
|
||||||
- **Networking**: Tailscale mesh
|
|
||||||
|
|
||||||
## Project landscape
|
|
||||||
|
|
||||||
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
|
|
||||||
|
|
||||||
Organized in thematic folders:
|
|
||||||
|
|
||||||
| Folder | Focus | Count |
|
|
||||||
|--------|-------|-------|
|
|
||||||
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
|
|
||||||
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
|
|
||||||
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
|
|
||||||
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
|
|
||||||
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
|
|
||||||
|
|
||||||
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
|
|
||||||
|
|
||||||
### Key active projects
|
|
||||||
|
|
||||||
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
|
|
||||||
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
|
|
||||||
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
|
|
||||||
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
|
|
||||||
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
|
|
||||||
|
|
||||||
## Knowledge base — actively use it
|
|
||||||
|
|
||||||
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
|
|
||||||
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
|
|
||||||
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
|
|
||||||
reference material — query it actively, not just when explicitly told.**
|
|
||||||
|
|
||||||
### When to query (treat as a reflex)
|
|
||||||
|
|
||||||
- **Before** starting a non-trivial task — search for prior art with the symptom
|
|
||||||
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
|
|
||||||
- **When debugging** — search for the error string, the stack frame, the affected
|
|
||||||
service. Past you may have already paid this tax.
|
|
||||||
- **Before adopting** a pattern, library, framework, or model name — check if it
|
|
||||||
was tried and rejected, or what the integration footguns are.
|
|
||||||
- **When making architectural decisions** — search for the domain + "ADR" or
|
|
||||||
"decision" to find prior reasoning before re-deriving it.
|
|
||||||
- **When a recommendation feels novel** — challenge yourself: "has this been
|
|
||||||
documented?" The brain often has it.
|
|
||||||
|
|
||||||
### When to write
|
|
||||||
|
|
||||||
After you discover something that **future-you would forget** and that **isn't
|
|
||||||
recoverable from the code, git log, or PR description alone**:
|
|
||||||
|
|
||||||
- Bugs whose root cause is non-obvious and generalisable beyond this project.
|
|
||||||
- Framework / library / model-name quirks that bit you and would bite anyone.
|
|
||||||
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
|
|
||||||
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
|
|
||||||
|
|
||||||
DON'T write project status, sprint progress, PR summaries, or "what I did this
|
|
||||||
session" — those rot fast and the originals are in git/gitea anyway. Brain
|
|
||||||
entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
|
||||||
|
|
||||||
### How to access (per harness)
|
|
||||||
|
|
||||||
| Harness | Query | Write |
|
|
||||||
|---------|-------|-------|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
|
||||||
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
|
|
||||||
on the koala k3s cluster; don't hardcode local-only model names into the
|
|
||||||
berget URL (see knowledge entry on namespace mismatches).
|
|
||||||
|
|
||||||
### Quick reflex checks
|
|
||||||
|
|
||||||
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
|
|
||||||
|
|
||||||
- "I think the issue might be..."
|
|
||||||
- "Let me try X and see..."
|
|
||||||
- "I'll just write a script to..."
|
|
||||||
- "This is probably a new bug..."
|
|
||||||
- "Has anyone done this before?" — *yes, probably, go check.*
|
|
||||||
|
|
||||||
## Client work rules
|
|
||||||
|
|
||||||
When working on a project tagged with a client name:
|
|
||||||
1. Never send code, data, or context to cloud APIs — use local models only
|
|
||||||
2. Never reference other client projects or their data
|
|
||||||
3. Keep all artifacts within the client's git org / directory
|
|
||||||
4. Treat everything as confidential unless told otherwise
|
|
||||||
|
|
||||||
## Harness-agnostic principles
|
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
|
||||||
|
|
||||||
## How context propagates
|
|
||||||
|
|
||||||
Canonical sources of truth:
|
|
||||||
- Universal: `~/dev/.context/AGENT.md` (this file)
|
|
||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
|
||||||
Workflow:
|
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
|
||||||
derived together. Push.
|
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
|
||||||
message telling you to stage the regenerated files.
|
|
||||||
|
|
||||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply
|
|
||||||
unconditionally on every host, every harness.
|
|
||||||
|
|
||||||
## Engineering Skills
|
|
||||||
|
|
||||||
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
|
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Project context
|
|
||||||
|
|
||||||
<!-- Canonical project context. Edit this, run `task context:sync`.
|
|
||||||
Root agent context from ~/dev/.context/AGENT.md is automatically
|
|
||||||
prepended for harnesses that don't walk the directory tree. -->
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
|
|
||||||
- **Name**: supervisor
|
|
||||||
- **Owner**: Mathias
|
|
||||||
- **Client**: personal
|
|
||||||
- **Repo**:
|
|
||||||
- **Status**: active
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- **Primary language**: Go
|
|
||||||
- **UI layer**: HTMX + Templ (when applicable)
|
|
||||||
- **Fallback languages**: Python, TypeScript (justify in PR if used)
|
|
||||||
- **Build**: Task (taskfile.dev), not Make
|
|
||||||
- **Containers**: Docker (compose for dev, k3s for deploy)
|
|
||||||
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
### Code style
|
|
||||||
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
|
|
||||||
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
|
|
||||||
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
|
|
||||||
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
|
|
||||||
|
|
||||||
### Architecture preferences
|
|
||||||
- Prefer standard library over frameworks (net/http over gin/echo)
|
|
||||||
- Dependency injection via constructor functions, not containers
|
|
||||||
- Configuration via environment variables, parsed at startup into a typed struct
|
|
||||||
- Structured logging via `slog`
|
|
||||||
|
|
||||||
### Git
|
|
||||||
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
|
|
||||||
- Branch naming: `feat/short-description`, `fix/short-description`
|
|
||||||
- PRs: one concern per PR, description explains *why* not *what*
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- No secrets in code, ever — use env vars or SOPS-encrypted files
|
|
||||||
- Client data never leaves local network unless explicitly cleared
|
|
||||||
- Dependencies: audit with `govulncheck` before adding
|
|
||||||
|
|
||||||
## MCP endpoints
|
|
||||||
|
|
||||||
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
|
|
||||||
|
|
||||||
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
|
|
||||||
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
|
|
||||||
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
|
|
||||||
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
|
|
||||||
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
|
|
||||||
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
|
|
||||||
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
|
|
||||||
(opt-in). Only `mode client-local` registers this endpoint.
|
|
||||||
|
|
||||||
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
|
|
||||||
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
|
|
||||||
the routing pod; brain tools moved to the brain MCP.
|
|
||||||
|
|
||||||
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
|
|
||||||
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
|
|
||||||
for shell scripts and non-MCP clients.
|
|
||||||
|
|
||||||
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
|
|
||||||
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
|
|
||||||
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
|
|
||||||
|
|
||||||
## Agent instructions
|
|
||||||
|
|
||||||
When acting as a coding agent on this project:
|
|
||||||
|
|
||||||
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
|
|
||||||
2. Run `task check` before committing (lint + test + vet)
|
|
||||||
3. If unsure about a convention, check `DECISIONS.md` or ask
|
|
||||||
4. Never modify files outside the project root without explicit permission
|
|
||||||
5. When adding a dependency, explain why in the commit message
|
|
||||||
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
|
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||||
ROUTING_IMAGE: git.d-ma.be/mathias/routing
|
ROUTING_IMAGE: git.d-ma.be/mathias/routing
|
||||||
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
||||||
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
chmod 600 ~/.ssh/infra_deploy_key
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
printf 'Host gitea.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
printf 'Host git.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
|
||||||
|
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ and climate/sustainability tech.
|
|||||||
|
|
||||||
These rules apply to every task across every project, regardless of harness.
|
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.
|
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.
|
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
|
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
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
the reason in PROJECT.md.
|
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
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| 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
|
- **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
|
- **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 -- <cmd>`. 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
|
## Infrastructure
|
||||||
|
|
||||||
Three machines on Tailscale:
|
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 |
|
| **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 |
|
| **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":"..."}` |
|
| **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`.
|
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||||
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
|
- **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
|
## 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:
|
| Task type | Load |
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
|-----------|------|
|
||||||
- **Code Review**: load `code-review` skill before any review
|
| Any feature or bug fix | `tdd` |
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -17,8 +17,6 @@ tasks:
|
|||||||
cmds: [bash scripts/context-sync.sh claude]
|
cmds: [bash scripts/context-sync.sh claude]
|
||||||
context:sync:agents:
|
context:sync:agents:
|
||||||
cmds: [bash scripts/context-sync.sh agents]
|
cmds: [bash scripts/context-sync.sh agents]
|
||||||
context:sync:cursor:
|
|
||||||
cmds: [bash scripts/context-sync.sh cursor]
|
|
||||||
|
|
||||||
# ── Development ────────────────────────────────────────────────────────────
|
# ── Development ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -90,12 +88,12 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- task: context:sync
|
- task: context:sync
|
||||||
- cmd: |
|
- cmd: |
|
||||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||||
if [ -n "$drift" ]; then
|
if [ -n "$drift" ]; then
|
||||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
echo "$drift" >&2
|
echo "$drift" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
|
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
|
||||||
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -61,6 +61,26 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"hall": enum("optional memory type (requires wing)", halls...),
|
"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",
|
"name": "brain_tunnel",
|
||||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
"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)
|
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/<wing>/<hall>/<slug>.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
|
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel,
|
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||||
// brain_ingest, brain_ingest_raw, brain_answer, brain_classify,
|
// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw,
|
||||||
// brain_graph, brain_context, session_log.
|
// brain_answer, brain_classify, brain_graph, brain_context, session_log.
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -177,6 +177,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainQuery(ctx, args)
|
return s.brainQuery(ctx, args)
|
||||||
case "brain_write":
|
case "brain_write":
|
||||||
return s.brainWrite(ctx, args)
|
return s.brainWrite(ctx, args)
|
||||||
|
case "brain_update":
|
||||||
|
return s.brainUpdate(ctx, args)
|
||||||
|
case "brain_get":
|
||||||
|
return s.brainGet(ctx, args)
|
||||||
case "brain_index":
|
case "brain_index":
|
||||||
return s.brainIndex(ctx, args)
|
return s.brainIndex(ctx, args)
|
||||||
case "brain_tunnel":
|
case "brain_tunnel":
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
names = append(names, t.(map[string]any)["name"].(string))
|
names = append(names, t.(map[string]any)["name"].(string))
|
||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []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_ingest_raw", "brain_ingest",
|
||||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||||
"session_log",
|
"session_log",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package vectorstore
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NumberedChunk pairs a chunk's body with the storage path it will use
|
// NumberedChunk pairs a chunk's body with the storage path it will use
|
||||||
@@ -66,6 +67,70 @@ func ChunkMarkdown(content string, maxBytes int) []string {
|
|||||||
}
|
}
|
||||||
out = append(out, splitAtParagraphs(s, maxBytes)...)
|
out = append(out, splitAtParagraphs(s, maxBytes)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final guarantee: no chunk exceeds maxBytes. A single heading-less,
|
||||||
|
// paragraph-less block (JSON-lines, minified content) survives the two
|
||||||
|
// passes above whole — splitAtParagraphs emits an over-budget paragraph
|
||||||
|
// rather than truncating prose. Hard-split any such chunk at line/rune
|
||||||
|
// boundaries so the embedder never rejects an over-context chunk.
|
||||||
|
final := make([]string, 0, len(out))
|
||||||
|
for _, c := range out {
|
||||||
|
if len(c) <= maxBytes {
|
||||||
|
final = append(final, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
final = append(final, hardSplit(c, maxBytes)...)
|
||||||
|
}
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardSplit slices s into pieces no larger than maxBytes, breaking at line
|
||||||
|
// boundaries where possible and otherwise mid-line at a UTF-8 rune boundary.
|
||||||
|
// Last resort for content that has neither headings nor blank-line paragraphs.
|
||||||
|
func hardSplit(s string, maxBytes int) []string {
|
||||||
|
var out []string
|
||||||
|
var cur strings.Builder
|
||||||
|
flush := func() {
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range strings.SplitAfter(s, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(line) > maxBytes {
|
||||||
|
flush()
|
||||||
|
out = append(out, runeSplit(line, maxBytes)...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 && cur.Len()+len(line) > maxBytes {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
cur.WriteString(line)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// runeSplit slices s into <=maxBytes pieces without splitting a UTF-8 rune.
|
||||||
|
func runeSplit(s string, maxBytes int) []string {
|
||||||
|
var out []string
|
||||||
|
for len(s) > maxBytes {
|
||||||
|
cut := maxBytes
|
||||||
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
if cut == 0 { // single rune wider than the budget; emit it whole
|
||||||
|
cut = maxBytes
|
||||||
|
}
|
||||||
|
out = append(out, s[:cut])
|
||||||
|
s = s[cut:]
|
||||||
|
}
|
||||||
|
if len(s) > 0 {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ func TestChunkMarkdown_SplitsAtHeadings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChunkMarkdown_HardSplitsHeadinglessOversizedBlock(t *testing.T) {
|
||||||
|
// A document with no headings and no blank-line paragraph breaks (e.g.
|
||||||
|
// JSON-lines like wiki/telos/decisions/human-intent-column.md). The old
|
||||||
|
// chunker emitted it as one over-budget chunk → nomic-embed returned
|
||||||
|
// "input length exceeds the context length" (400). Every chunk must now
|
||||||
|
// fit the budget, with no content lost.
|
||||||
|
maxBytes := 200
|
||||||
|
src := strings.Repeat("x", 1000) // one 1000-byte blob, no headings, no \n\n
|
||||||
|
out := vectorstore.ChunkMarkdown(src, maxBytes)
|
||||||
|
|
||||||
|
require.Greater(t, len(out), 1, "oversized blob must be split")
|
||||||
|
for i, c := range out {
|
||||||
|
assert.LessOrEqual(t, len(c), maxBytes, "chunk %d over budget: %d bytes", i, len(c))
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1000, strings.Count(strings.Join(out, ""), "x"), "no content lost")
|
||||||
|
}
|
||||||
|
|
||||||
func TestChunkMarkdown_FurtherSplitsOversizedSection(t *testing.T) {
|
func TestChunkMarkdown_FurtherSplitsOversizedSection(t *testing.T) {
|
||||||
// One H2 section with 4 paragraphs of ~80 chars each, limit 100.
|
// One H2 section with 4 paragraphs of ~80 chars each, limit 100.
|
||||||
src := "## big\n\n" +
|
src := "## big\n\n" +
|
||||||
|
|||||||
+3
-32
@@ -40,10 +40,9 @@ if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
|||||||
echo " Root context: $ROOT_CONTEXT"
|
echo " Root context: $ROOT_CONTEXT"
|
||||||
else
|
else
|
||||||
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
||||||
# adapters (AGENTS.md, .cursorrules, .aider.conventions.md, system-prompt.txt)
|
# adapters (AGENTS.md, system-prompt.txt) require the root context to
|
||||||
# require the root context to regenerate correctly, so we skip them entirely
|
# regenerate correctly, so we skip them entirely and only regenerate CLAUDE.md
|
||||||
# and only regenerate CLAUDE.md (which is project-only and inherits root via
|
# (which is project-only and inherits root via tree walk in Claude Code itself).
|
||||||
# tree walk in Claude Code itself).
|
|
||||||
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
||||||
echo "Syncing project context from $PROJECT_FILE..."
|
echo "Syncing project context from $PROJECT_FILE..."
|
||||||
cat "$PROJECT_FILE" > CLAUDE.md
|
cat "$PROJECT_FILE" > CLAUDE.md
|
||||||
@@ -78,30 +77,6 @@ generate_agents() {
|
|||||||
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
|
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Cursor ───────────────────────────────────────────────────
|
|
||||||
generate_cursor() {
|
|
||||||
{
|
|
||||||
echo "# Cursor rules — auto-generated"
|
|
||||||
echo "# Do not edit. Run: task context:sync"
|
|
||||||
echo ""
|
|
||||||
root_block
|
|
||||||
cat "$PROJECT_FILE"
|
|
||||||
} > .cursorrules
|
|
||||||
echo " → .cursorrules (root + project)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Aider ────────────────────────────────────────────────────
|
|
||||||
generate_aider() {
|
|
||||||
{ root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md
|
|
||||||
if [ ! -f .aider.conf.yml ]; then
|
|
||||||
cat > .aider.conf.yml << 'YAML'
|
|
||||||
read: .aider.conventions.md
|
|
||||||
auto-commits: false
|
|
||||||
YAML
|
|
||||||
fi
|
|
||||||
echo " → .aider.conventions.md (root + project)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
|
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
|
||||||
generate_system_prompt() {
|
generate_system_prompt() {
|
||||||
{
|
{
|
||||||
@@ -142,8 +117,6 @@ echo "Syncing project context from $PROJECT_FILE..."
|
|||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
generate_claude
|
generate_claude
|
||||||
generate_agents
|
generate_agents
|
||||||
generate_cursor
|
|
||||||
generate_aider
|
|
||||||
generate_system_prompt
|
generate_system_prompt
|
||||||
generate_mcp
|
generate_mcp
|
||||||
else
|
else
|
||||||
@@ -151,8 +124,6 @@ else
|
|||||||
case "$adapter" in
|
case "$adapter" in
|
||||||
claude) generate_claude ;;
|
claude) generate_claude ;;
|
||||||
agents) generate_agents ;;
|
agents) generate_agents ;;
|
||||||
cursor) generate_cursor ;;
|
|
||||||
aider) generate_aider ;;
|
|
||||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||||
mcp) generate_mcp ;;
|
mcp) generate_mcp ;;
|
||||||
*) echo "Unknown adapter: $adapter" ;;
|
*) echo "Unknown adapter: $adapter" ;;
|
||||||
|
|||||||
Reference in New Issue
Block a user