Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e69698343 | ||
|
|
eb2acfeeb8 | ||
|
|
6e5a9cfce3 | ||
|
|
25b79f057e | ||
|
|
47bbac4b9f | ||
|
|
8a27ead91c | ||
|
|
1a9be0cfb1 | ||
|
|
dc2265da0b | ||
|
|
c176b71b3d | ||
|
|
4e8bdc137e | ||
|
|
42235dcb6a | ||
|
|
05330ad54a | ||
|
|
692244d213 | ||
|
|
d98a305985 | ||
|
|
670325e519 | ||
|
|
54237af7ad | ||
|
|
b6ceccca26 | ||
|
|
36a45068ab | ||
|
|
055af368cd | ||
|
|
eb9e3e69a9 | ||
|
|
b7cf0efb9d | ||
|
|
125c99bb46 | ||
|
|
3e537bd582 | ||
|
|
4e6fc7aeee | ||
|
|
9a232ad444 | ||
|
|
3da0eca9b4 | ||
|
|
7d1213451c | ||
|
|
7c9293f4ed | ||
|
|
6e0155a2ab | ||
|
|
1cea2c9f78 | ||
|
|
9dcd60931a | ||
|
|
1fac90ed2a | ||
|
|
cb9c2513a4 | ||
|
|
3617a6c386 | ||
|
|
1938170131 | ||
|
|
b34717e6b8 | ||
|
|
d8d7e9a307 | ||
|
|
f0055483a3 | ||
|
|
6d014c1d0f | ||
|
|
1001acfb44 | ||
|
|
6ad275b505 | ||
|
|
b600cc986c | ||
|
|
9bdab1c48c | ||
|
|
6520c2fc4b | ||
|
|
fcbd1072b6 | ||
|
|
3b7706b358 | ||
|
|
394a227877 | ||
|
|
0f84ab5eda | ||
|
|
5b57843346 | ||
|
|
ee1204d76b | ||
|
|
6d58336ce2 | ||
|
|
0785f14220 | ||
|
|
a7db0dd00d | ||
|
|
fb59c390e2 | ||
|
|
00e5f62c8e | ||
|
|
b9d03316fd | ||
|
|
da9bdc4cbb | ||
|
|
dcb9ff4a56 | ||
|
|
0454527b83 | ||
|
|
ef11864121 | ||
|
|
14b04a25cb | ||
|
|
66a9b8e725 | ||
|
|
9f8fb9c138 | ||
|
|
d39a18dd69 | ||
|
|
5288554338 | ||
|
|
2368564523 | ||
|
|
0e28b2125b | ||
|
|
723dab51ae | ||
|
|
06e21c019e | ||
|
|
76514215f4 | ||
|
|
7cf5bc221d | ||
|
|
f78a5474a5 | ||
|
|
c307b72bd5 | ||
|
|
38a2e91002 | ||
|
|
77f5e06d6b | ||
|
|
202212e8d5 | ||
|
|
b7938d4636 | ||
|
|
a1997838b0 | ||
|
|
77680c7445 | ||
|
|
d7a842f356 | ||
|
|
aad90f2dfe | ||
|
|
07fca9ee73 | ||
|
|
f6bf9b5f57 | ||
|
|
6606b38a76 | ||
|
|
4cfc98de56 | ||
|
|
0ac165cca3 | ||
|
|
43f92e3102 | ||
|
|
98cfae595c | ||
|
|
2a595b5a92 | ||
|
|
b7a2cc5fdf | ||
|
|
db638cca11 | ||
|
|
38579598e0 | ||
|
|
d6fa92b176 | ||
|
|
9173f9058d | ||
|
|
3e84a41fed | ||
|
|
0e0571c7da | ||
|
|
7a27cf71a2 | ||
|
|
63df6d3283 | ||
|
|
f04b03e07e | ||
|
|
6c61f93146 | ||
|
|
95a69fc2c1 | ||
|
|
bb8bc0478c | ||
|
|
a961a3c064 | ||
|
|
bec28f9014 | ||
|
|
b62ac57382 | ||
|
|
aa918388b9 | ||
|
|
e8dbcf6eef | ||
|
|
0eeb1df4a2 | ||
|
|
9febb1bba1 | ||
|
|
5dc247b994 | ||
|
|
2125558196 | ||
|
|
2beaac2feb | ||
|
|
525811bc1a | ||
|
|
bad0581623 | ||
|
|
a94b860c2e | ||
|
|
f8cf27e5de | ||
|
|
49b188e9c9 | ||
|
|
bc011cc1f0 | ||
|
|
2726896079 | ||
|
|
2b7bbe38c7 | ||
|
|
1b00cbc0ae | ||
|
|
4f78fecd06 | ||
|
|
d5f112b600 | ||
|
|
ea9518e712 | ||
|
|
e34cd6c12b | ||
|
|
3084c4173d | ||
|
|
72be87b4e7 | ||
|
|
153ef6ccac | ||
|
|
2148565ee6 | ||
|
|
f43e0bccbf | ||
|
|
f53ee18cb6 | ||
|
|
c153e9105c | ||
|
|
ce96a6a571 | ||
|
|
ca22df2d6a | ||
|
|
e49b36e463 |
@@ -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
|
|
||||||
@@ -7,6 +7,29 @@
|
|||||||
"supervisor": {
|
"supervisor": {
|
||||||
"url": "http://localhost:3200/mcp",
|
"url": "http://localhost:3200/mcp",
|
||||||
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
||||||
|
},
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-28
@@ -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
|
||||||
@@ -45,14 +53,66 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
the tiered merge model in `assessor-loop`
|
||||||
|
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||||
|
what it sells instead of merging everything on green and claiming otherwise.
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
generated artifact written to the same path by both (branching turns that into a
|
||||||
|
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||||
|
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||||
|
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||||
|
timestamp generated artifacts. Do not reach for a branch.
|
||||||
|
|
||||||
|
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||||
|
issues, review comments live on a thread separate from the issue's, and a
|
||||||
|
merged-and-deleted branch cannot be amended (brain:
|
||||||
|
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||||
|
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||||
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
|
PROJECT.md.
|
||||||
|
|
||||||
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
|
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||||
|
actual last tag — stated versions in docs drift stale.
|
||||||
|
- **Push** main and the tag (CI is the gate).
|
||||||
|
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||||
|
the reusable patterns and the footguns that would bite anyone again, never
|
||||||
|
project status. See *Knowledge base — when to write* below.
|
||||||
|
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||||
|
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||||
|
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||||
|
source pointer.
|
||||||
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -64,7 +124,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -73,7 +133,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -83,6 +148,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:
|
||||||
@@ -91,10 +176,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -162,7 +247,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
|
||||||
@@ -190,10 +275,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||||
|
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
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.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -205,15 +291,29 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||||
|
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||||
|
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||||
|
up** from cwd and takes the first `AGENTS.md` it finds
|
||||||
|
(user-level `~/.vibe/AGENTS.md`)
|
||||||
|
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||||
|
any harness, it is for pasting
|
||||||
|
|
||||||
|
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||||
|
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||||
|
inside that repo, so this works — but by construction, not by luck.
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -224,15 +324,30 @@ 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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||||
|
|
||||||
Key skills:
|
```
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
- **Code Review**: load `code-review` skill before any review
|
/plugin install skills@mathias
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
```
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
|
||||||
|
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||||
|
|
||||||
|
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||||
|
|
||||||
|
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||||
|
|
||||||
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
| Task type | Load |
|
||||||
|
|-----------|------|
|
||||||
|
| Any feature or bug fix | `tdd` |
|
||||||
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
-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
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Presence of this file marks this repo as opt-in for headless dispatch.
|
||||||
|
# See dispatch#3.
|
||||||
+31
-70
@@ -1,6 +1,6 @@
|
|||||||
name: cd
|
name: cd
|
||||||
|
|
||||||
on:
|
"on":
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI"]
|
workflows: ["CI"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
@@ -13,9 +13,8 @@ jobs:
|
|||||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
||||||
environment: staging
|
environment: staging
|
||||||
env:
|
env:
|
||||||
INGESTION_IMAGE: gitea.d-ma.be/mathias/ingestion
|
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||||
ROUTING_IMAGE: gitea.d-ma.be/mathias/routing
|
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
||||||
INFRA_REPO: git@gitea.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
|
||||||
@@ -41,58 +40,52 @@ jobs:
|
|||||||
|
|
||||||
echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}"
|
echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
- name: Build and push routing image
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
trap 'rm -f /tmp/routing-image.tar' EXIT
|
|
||||||
IMAGE_TAG="${{ github.sha }}"
|
|
||||||
echo "Building ${ROUTING_IMAGE}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
buildctl --addr "${BUILDKIT_HOST}" build \
|
|
||||||
--frontend dockerfile.v0 \
|
|
||||||
--local context=. \
|
|
||||||
--local dockerfile=. \
|
|
||||||
--opt filename=Dockerfile.routing \
|
|
||||||
--opt build-arg:VERSION="${IMAGE_TAG}" \
|
|
||||||
--output type=oci,dest=/tmp/routing-image.tar
|
|
||||||
|
|
||||||
skopeo copy \
|
|
||||||
oci-archive:/tmp/routing-image.tar \
|
|
||||||
docker://${ROUTING_IMAGE}:${IMAGE_TAG} \
|
|
||||||
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
|
|
||||||
|
|
||||||
echo "Built and pushed ${ROUTING_IMAGE}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
- name: Update infra repo
|
- name: Update infra repo
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||||
IMAGE_TAG="${{ github.sha }}"
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
|
# infra#311: github.sha is never empty in practice, but the manifest
|
||||||
|
# patch below is the same shape that took Flux down estate-wide on
|
||||||
|
# 2026-08-25 -- an empty tag yields `image: <repo>:`, which is invalid
|
||||||
|
# YAML and fails the ENTIRE Kustomization, not just this app. The
|
||||||
|
# assert costs nothing and removes the class.
|
||||||
|
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
||||||
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
|
# In-cluster DNS to gitea's SSH NodePort service, not 127.0.0.1:30022
|
||||||
|
# (that only worked when act_runner ran on koala's bare host network;
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
# from inside the containerized runner's own pod netns, loopback
|
||||||
|
# never reaches the host — "Connection refused", found 2026-07-27).
|
||||||
|
#
|
||||||
|
# Pass as -o overrides on the ssh invocation itself, NOT appended to
|
||||||
|
# ~/.ssh/config: $HOME (/data) is a PVC that persists across job
|
||||||
|
# runs on this runner (same "workspace not ephemeral" class as
|
||||||
|
# brain: act-runner-host-executor-tmp-persists), so an appended
|
||||||
|
# line here would pile up duplicate `Host git.d-ma.be` blocks
|
||||||
|
# across every run — ssh_config is first-match-wins, so a stale
|
||||||
|
# entry from an earlier failed run would silently shadow this
|
||||||
|
# fix forever (exactly what happened once already: this fix's
|
||||||
|
# own first attempt got appended AFTER an already-stale entry
|
||||||
|
# and lost). CLI -o options always win regardless of file state,
|
||||||
|
# so this step is safe to re-run any number of times.
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes -o HostName=gitea-ssh-nodeport.gitea.svc.cluster.local -o Port=22 -o StrictHostKeyChecking=no" \
|
||||||
git clone "${INFRA_REPO}" /tmp/infra-update
|
git clone "${INFRA_REPO}" /tmp/infra-update
|
||||||
|
|
||||||
cd /tmp/infra-update
|
cd /tmp/infra-update
|
||||||
|
|
||||||
sed -i "s|gitea.d-ma.be/mathias/ingestion:.*|gitea.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
sed -i "s|git.d-ma.be/mathias/ingestion:.*|git.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
|
||||||
"k3s/apps/supervisor/ingestion-deployment.yaml"
|
"k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
|
|
||||||
sed -i "s|gitea.d-ma.be/mathias/routing:.*|gitea.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
|
|
||||||
"k3s/apps/routing/deployment.yaml"
|
|
||||||
|
|
||||||
git config user.email "cd-bot@d-ma.be"
|
git config user.email "cd-bot@d-ma.be"
|
||||||
git config user.name "CD Bot"
|
git config user.name "CD Bot"
|
||||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml" \
|
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
"k3s/apps/routing/deployment.yaml"
|
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||||
git commit -m "chore(deploy): ingestion+routing → ${IMAGE_TAG}"
|
|
||||||
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 push
|
git push
|
||||||
|
|
||||||
echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}"
|
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||||
|
|
||||||
- name: Trigger Flux reconcile (immediate)
|
- name: Trigger Flux reconcile (immediate)
|
||||||
run: |
|
run: |
|
||||||
@@ -103,7 +96,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Wait for Flux to apply new ingestion image
|
- name: Wait for Flux to apply new ingestion image
|
||||||
run: |
|
run: |
|
||||||
EXPECTED="gitea.d-ma.be/mathias/ingestion:${{ github.sha }}"
|
EXPECTED="git.d-ma.be/mathias/ingestion:${{ github.sha }}"
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
CURRENT=$(kubectl get deploy ingestion -n supervisor \
|
CURRENT=$(kubectl get deploy ingestion -n supervisor \
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||||
@@ -132,35 +125,3 @@ jobs:
|
|||||||
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Wait for Flux to apply new routing image
|
|
||||||
run: |
|
|
||||||
EXPECTED="gitea.d-ma.be/mathias/routing:${{ github.sha }}"
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
CURRENT=$(kubectl get deploy routing -n routing \
|
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
|
||||||
if [ "$CURRENT" = "$EXPECTED" ]; then
|
|
||||||
echo "✓ Flux applied routing image after ${i}s"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
kubectl get deploy routing -n routing \
|
|
||||||
-o jsonpath='{.spec.template.spec.containers[0].image}' \
|
|
||||||
| grep -qx "$EXPECTED" \
|
|
||||||
|| { echo "✗ Flux did not apply routing image within 60s"; exit 1; }
|
|
||||||
|
|
||||||
- name: Verify routing rollout
|
|
||||||
run: |
|
|
||||||
kubectl rollout status deployment/routing \
|
|
||||||
--namespace routing \
|
|
||||||
--timeout=120s \
|
|
||||||
|| {
|
|
||||||
echo "── pod status ──"
|
|
||||||
kubectl get pods -n routing -o wide
|
|
||||||
echo "── events ──"
|
|
||||||
kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20
|
|
||||||
echo "── describe ──"
|
|
||||||
kubectl describe pods -n routing -l app=routing | tail -40
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
"on":
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pre-commit secret scan for the hyperguild repo.
|
||||||
|
#
|
||||||
|
# task check -> security:gitleaks enforces this in CI, but CI is after the
|
||||||
|
# push. This catches a staged secret before it ever leaves the machine --
|
||||||
|
# the infra#39 rotation pass found exactly this class of leak (a printed
|
||||||
|
# credential, committed, archived into git) and there was no local gate to
|
||||||
|
# have caught it. Mirrors infra's own .githooks/pre-commit (infra#326).
|
||||||
|
#
|
||||||
|
# Enable once per clone: git config core.hooksPath .githooks
|
||||||
|
# Bypass if absolutely needed: git commit --no-verify
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITLEAKS="$(command -v gitleaks || true)"
|
||||||
|
if [[ -z "$GITLEAKS" && -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ]]; then
|
||||||
|
GITLEAKS="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$GITLEAKS" ]]; then
|
||||||
|
echo "⚠ gitleaks not installed — skipping local secret scan (CI still enforces it)."
|
||||||
|
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ Scanning staged changes for secrets..."
|
||||||
|
if ! "$GITLEAKS" protect --staged --redact --config .gitleaks.toml >/dev/null 2>&1; then
|
||||||
|
echo " ✗ gitleaks found a secret in the staged changes."
|
||||||
|
echo " Review with: gitleaks protect --staged --redact --config .gitleaks.toml -v"
|
||||||
|
echo " If it is a false positive, allowlist it in .gitleaks.toml or add a"
|
||||||
|
echo " '# gitleaks:allow' comment. Do NOT --no-verify a real secret."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ no staged secrets"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# gitleaks config for the hyperguild repo (infra#39 — leak prevention pass,
|
||||||
|
# Phase 3 checklist item: "gitleaks pre-commit hook in infra AND hyperguild").
|
||||||
|
#
|
||||||
|
# Ported from mathias/infra's .gitleaks.toml (2026-08-04) — same homelab
|
||||||
|
# token-shape rules, minus the SOPS/searxng allowlists infra needed (this
|
||||||
|
# repo doesn't use SOPS).
|
||||||
|
|
||||||
|
title = "hyperguild gitleaks config"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
# --- Homelab-specific rules -------------------------------------------------
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "homelab-static-bearer"
|
||||||
|
description = "Homelab MCP/LLM static bearer or API key assigned a long literal value"
|
||||||
|
regex = '''(?i)\b(DMABE_[A-Z0-9_]+|[A-Z0-9_]*MCP_TOKEN|ROUTING_MCP_TOKEN|INFRA_MCP_TOKEN|BRAIN_MCP_TOKEN|GITEA_MCP_TOKEN|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|DMABE_LLMAPI_KEY|BRAIN_PG_DSN)\s*[:=]\s*['"]?([A-Za-z0-9/_+.\-]{16,})['"]?'''
|
||||||
|
keywords = ["dmabe_", "mcp_token", "litellm_master_key", "litellm_salt_key", "llmapi_key", "brain_pg_dsn"]
|
||||||
|
[[rules.allowlists]]
|
||||||
|
description = "Env indirection is not a literal secret"
|
||||||
|
regexes = [
|
||||||
|
'''os\.environ''',
|
||||||
|
'''valueFrom''',
|
||||||
|
'''secretKeyRef''',
|
||||||
|
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||||
|
'''REDACTED''',
|
||||||
|
'''<[A-Z_]+>''',
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "homelab-authorization-bearer"
|
||||||
|
description = "Hardcoded Authorization: Bearer header"
|
||||||
|
regex = '''(?i)authorization['"]?\s*[:=]\s*['"]?bearer\s+([A-Za-z0-9/_+.\-=]{16,})'''
|
||||||
|
keywords = ["authorization", "bearer"]
|
||||||
|
[[rules.allowlists]]
|
||||||
|
description = "Env indirection is not a literal secret"
|
||||||
|
regexes = [
|
||||||
|
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||||
|
'''os\.environ''',
|
||||||
|
'''REDACTED''',
|
||||||
|
'''<[A-Z_]+>''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Global allowlist: claudewatcher's own scrubber test fixtures ------------
|
||||||
|
# ingestion/internal/claudewatcher/{scrubber,watcher}_test.go deliberately
|
||||||
|
# contain fake secret-shaped literals to test that the scrubber detects and
|
||||||
|
# redacts them. Verified 2026-08-04: all 9 findings here are test fixtures
|
||||||
|
# (github-pat, jwt, generic-api-key, homelab-authorization-bearer rules) plus
|
||||||
|
# 1 doc finding that was gitleaks matching the literal placeholder word
|
||||||
|
# "REDACTED" in a plan doc — not a real secret in either case.
|
||||||
|
[[allowlists]]
|
||||||
|
description = "claudewatcher scrubber test fixtures — deliberately fake secrets"
|
||||||
|
paths = [
|
||||||
|
'''ingestion/internal/claudewatcher/scrubber_test\.go$''',
|
||||||
|
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# infra#344: the allowlist that used to sit here is gone, and so is the reason
|
||||||
|
# for it. It suppressed docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
|
||||||
|
# on the stated grounds that "extracted Secret == 'REDACTED' exactly ... the
|
||||||
|
# placeholder text itself, not a real credential" (verified 2026-08-04). That was
|
||||||
|
# not what the file contained: line 1054 carried a real 40-char hex Gitea PAT in
|
||||||
|
# an `Authorization: token ...` header. Confirmed dead by side-effect — GET
|
||||||
|
# /api/v1/user with it returns 401 — and redacted in that file, so there is now
|
||||||
|
# nothing to allowlist.
|
||||||
|
#
|
||||||
|
# The `paths` entry also made the block far broader than its description: gitleaks
|
||||||
|
# ORs global allowlist clauses and ignores `condition = "AND"`, so the whole file
|
||||||
|
# was immune, not just lines matching /REDACTED/. A live token added to that doc
|
||||||
|
# would have been suppressed on the strength of a note about a different value.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Historical gitleaks findings, dispositioned (infra#326 / infra#317).
|
||||||
|
#
|
||||||
|
# WHY THIS FILE AND NOT .gitleaks.toml:
|
||||||
|
# This is a finding in git HISTORY, surfaced by the full-history audit
|
||||||
|
# (`task security:gitleaks:history`). The working-tree scan that gates commits
|
||||||
|
# (`task security:gitleaks`, the pre-commit hook, CI) is clean. Suppressing by
|
||||||
|
# PATH in .gitleaks.toml would also blind the working-tree scan for that file
|
||||||
|
# forever; a fingerprint pins one finding in one commit, so a NEW secret in the
|
||||||
|
# same file still fires.
|
||||||
|
#
|
||||||
|
# History is not rewritten here, so the remediation for a real leak is
|
||||||
|
# ROTATION -- making the committed value worthless -- never scrubbing.
|
||||||
|
#
|
||||||
|
# Dispositioned 2026-08-29. Re-check with: task security:gitleaks:history
|
||||||
|
|
||||||
|
# --- Real credential, ALREADY DEAD -------------------------------------------
|
||||||
|
# A Gitea PAT pasted into a CD-watching command in a planning doc
|
||||||
|
# (`Authorization: token <40-hex>`, last_eight 3105a670).
|
||||||
|
#
|
||||||
|
# Verified dead 2026-08-29: authenticating against
|
||||||
|
# GET https://git.d-ma.be/api/v1/user with this exact value returns 401, so the
|
||||||
|
# committed token no longer grants anything. It was almost certainly invalidated
|
||||||
|
# by the early-August rotation of the `mathias` PATs (the same rotation whose
|
||||||
|
# fan-out caused infra#260, where CI copies of the old value kept failing).
|
||||||
|
#
|
||||||
|
# No rotation action outstanding. Kept as a fingerprint rather than removed from
|
||||||
|
# history because history cannot be rewritten here.
|
||||||
|
#
|
||||||
|
# The generalisable lesson is recorded separately: a credential printed into a
|
||||||
|
# doc/transcript is permanent, so the only remedy is rotating it -- which in
|
||||||
|
# this case had already happened for unrelated reasons, by luck rather than by
|
||||||
|
# the leak being noticed.
|
||||||
|
c9310b1079478bf4430b0a2dfb25445d7ccc9b59:docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md:curl-auth-header:1053
|
||||||
@@ -6,6 +6,13 @@
|
|||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://git-mcp.d-ma.be/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -40,14 +48,66 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
the tiered merge model in `assessor-loop`
|
||||||
|
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||||
|
what it sells instead of merging everything on green and claiming otherwise.
|
||||||
|
|
||||||
**Exception — external contributor or client four-eyes requirement:** Use
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
generated artifact written to the same path by both (branching turns that into a
|
||||||
|
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||||
|
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||||
|
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||||
|
timestamp generated artifacts. Do not reach for a branch.
|
||||||
|
|
||||||
|
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||||
|
issues, review comments live on a thread separate from the issue's, and a
|
||||||
|
merged-and-deleted branch cannot be amended (brain:
|
||||||
|
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||||
|
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||||
|
|
||||||
|
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||||
|
when a human reviewer outside the project is required. Document the reason in
|
||||||
|
PROJECT.md.
|
||||||
|
|
||||||
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
|
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||||
|
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||||
|
actual last tag — stated versions in docs drift stale.
|
||||||
|
- **Push** main and the tag (CI is the gate).
|
||||||
|
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||||
|
the reusable patterns and the footguns that would bite anyone again, never
|
||||||
|
project status. See *Knowledge base — when to write* below.
|
||||||
|
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||||
|
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||||
|
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||||
|
source pointer.
|
||||||
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
@@ -59,7 +119,7 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||||
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
|
||||||
| Logging | slog (structured) | — | — |
|
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
|
||||||
| Testing | Table-driven, testify | — | — |
|
| Testing | Table-driven, testify | — | — |
|
||||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||||
|
|
||||||
@@ -68,7 +128,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
|||||||
## Code conventions
|
## Code conventions
|
||||||
|
|
||||||
- **Go style**: golines, gofumpt, golangci-lint
|
- **Go style**: golines, gofumpt, golangci-lint
|
||||||
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
|
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
|
||||||
|
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
|
||||||
|
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
|
||||||
|
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
|
||||||
|
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
|
||||||
|
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
|
||||||
- **Naming**: stdlib conventions, no stuttering
|
- **Naming**: stdlib conventions, no stuttering
|
||||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||||
@@ -78,6 +143,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:
|
||||||
@@ -86,10 +171,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s cluster across all three machines
|
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -157,7 +242,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
|
||||||
@@ -185,10 +270,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||||
|
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
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.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -200,15 +286,29 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||||
|
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||||
|
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||||
|
up** from cwd and takes the first `AGENTS.md` it finds
|
||||||
|
(user-level `~/.vibe/AGENTS.md`)
|
||||||
|
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||||
|
any harness, it is for pasting
|
||||||
|
|
||||||
|
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||||
|
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||||
|
inside that repo, so this works — but by construction, not by luck.
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -219,15 +319,30 @@ 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 live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||||
|
|
||||||
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
|
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||||
|
|
||||||
Key skills:
|
```
|
||||||
- **TDD**: always write tests first — load `tdd` skill
|
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||||
- **Code Review**: load `code-review` skill before any review
|
/plugin install skills@mathias
|
||||||
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
|
```
|
||||||
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
|
|
||||||
|
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||||
|
|
||||||
|
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||||
|
|
||||||
|
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||||
|
|
||||||
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
| Task type | Load |
|
||||||
|
|-----------|------|
|
||||||
|
| Any feature or bug fix | `tdd` |
|
||||||
|
| Refactor or design | `clean-code` or `solid` |
|
||||||
|
| Debug | `problem-analysis` |
|
||||||
|
| Review code or PRs | `code-review` |
|
||||||
|
| Frame a problem before coding | `problem-analysis` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,74 @@ Record *why* things are the way they are. Future-you will thank present-you.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-28 — three active harnesses: hyperguild, agentsquad, Crush (extends earlier boundary decision)
|
||||||
|
|
||||||
|
**Context:** After wiring Crush to LiteLLM in May 2026, there are now three active harnesses.
|
||||||
|
The earlier boundary decision only covered hyperguild vs agentsquad. Crush's role was undefined.
|
||||||
|
|
||||||
|
**Decision:** Three harnesses, three distinct roles, shared skills layer.
|
||||||
|
|
||||||
|
| Harness | Engine | Primary use | Brain MCP? | Routing pod? | Skills? |
|
||||||
|
|---------|--------|-------------|------------|--------------|---------|
|
||||||
|
| **hyperguild** | Claude Code + MCP | Disciplined solo coding sessions, TDD/review/debug workflows | Yes | Yes | Yes (SKILL.md) |
|
||||||
|
| **agentsquad** | OpenCode + LiteLLM | Multi-agent task execution, executor/reviewer pipelines | No | No (own routing) | Yes (SKILL.md) |
|
||||||
|
| **Crush** | Charmbracelet TUI + LiteLLM | Interactive local coding, quick iterations on flamingo | No (not yet) | No (direct LiteLLM) | Yes (SKILL.md) |
|
||||||
|
|
||||||
|
**Crush specifics (as of 2026-05-28):**
|
||||||
|
- Config: `~/.config/crush/crush.json` on flamingo (see brain: `homelab/facts/crush-litellm-wiring-2026-05`)
|
||||||
|
- Connects directly to LiteLLM at `http://koala:4000/v1/` using `sk-local-123`
|
||||||
|
- Auth type: `openai-compat` (not `openai`)
|
||||||
|
- Does NOT go through the routing pod — model selection is manual in the Crush UI
|
||||||
|
- Brain MCP not wired — Crush has no MCP client capability today; revisit if Crush adds MCP support
|
||||||
|
|
||||||
|
**Shared across all three:**
|
||||||
|
- `mathias/skills` — any SKILL.md file works in all three harnesses
|
||||||
|
- LiteLLM proxy on koala (`http://koala:4000/v1/`) — Crush and agentsquad both route through it; hyperguild does too for local model calls
|
||||||
|
|
||||||
|
**Consequences:** No consolidation needed. crush.json must be kept in sync when litellm_config.yaml model names change. The `crush.json` canonical location is `~/.config/crush/crush.json` on flamingo — not yet tracked in a dotfiles repo (track as tech debt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-28 — "field benchmark" for local models = pass-rate at scale (supersedes GOTTH eval suite)
|
||||||
|
|
||||||
|
**Context:** The GOTTH eval suite (45 offline prompts across 5 categories) was replaced by
|
||||||
|
a "field benchmark" in May 2026, but the replacement was never defined concretely.
|
||||||
|
|
||||||
|
**Decision:** The field benchmark is per-skill pass rate over real routing pod usage,
|
||||||
|
collected automatically by `internal/routing/passrate.go` and exposed at:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /pass-rate?skill=<name>&window=<duration>
|
||||||
|
```
|
||||||
|
|
||||||
|
No separate eval suite. No synthetic prompts. The benchmark runs itself once the routing
|
||||||
|
pod receives real traffic. Target: 30-day rolling window per skill, reviewed monthly.
|
||||||
|
|
||||||
|
**Bootstrap note:** With no session history, `passrate.go` returns `nil` and the router
|
||||||
|
defaults to the thinking model for every call. The fast-model path activates only after
|
||||||
|
real pass-rate data accumulates. Seed with real usage — do not pre-populate.
|
||||||
|
|
||||||
|
**Consequences:** Zero maintenance overhead for the benchmark. The tradeoff is that results
|
||||||
|
are only meaningful after ~2 weeks of real usage, and skills that are rarely invoked will
|
||||||
|
have statistically thin pass-rate data. Revisit if a skill has fewer than 20 calls in 30 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-28 — brain injection in skill handlers: review is done, others unverified
|
||||||
|
|
||||||
|
**Context:** The April 2026 scope reset listed "brain_query injection into skill handlers"
|
||||||
|
as the top priority. As of 2026-05-28, `internal/skills/review/handlers.go` calls
|
||||||
|
`brain.Query(ctx, ...)` before dispatching to the LLM — confirmed in code review.
|
||||||
|
Status of debug, retrospective, and trainer handlers is unverified.
|
||||||
|
|
||||||
|
**Decision:** Treat review as the reference implementation. Verify debug, retrospective,
|
||||||
|
trainer against the same pattern before shipping new skill work. Tracked in issue #32.
|
||||||
|
|
||||||
|
**Consequences:** The April concern may be stale for review. A one-pass audit of the other
|
||||||
|
three skill handlers closes this fully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-08 — AGENTS.md as cross-tool standard, not CLAUDE.md
|
## 2026-04-08 — AGENTS.md as cross-tool standard, not CLAUDE.md
|
||||||
|
|
||||||
**Context**: Multiple tools (Crush, Pi, Antigravity) read `AGENTS.md` natively. Claude Code reads `CLAUDE.md`. Building on `CLAUDE.md` as the primary format locks into one vendor.
|
**Context**: Multiple tools (Crush, Pi, Antigravity) read `AGENTS.md` natively. Claude Code reads `CLAUDE.md`. Building on `CLAUDE.md` as the primary format locks into one vendor.
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
# ── Build stage ───────────────────────────────────────────────────────────────
|
|
||||||
FROM golang:1.26-bookworm AS builder
|
|
||||||
|
|
||||||
ARG VERSION=dev
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
|
||||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
|
|
||||||
-o /out/routing ./cmd/routing
|
|
||||||
|
|
||||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
|
||||||
FROM gcr.io/distroless/base-debian12
|
|
||||||
|
|
||||||
COPY --from=builder /out/routing /usr/local/bin/routing
|
|
||||||
COPY config/ /app/config/
|
|
||||||
|
|
||||||
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
|
|
||||||
ENV ROUTING_PORT=3210
|
|
||||||
|
|
||||||
EXPOSE 3210
|
|
||||||
|
|
||||||
USER 65532:65532
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/routing"]
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Icebox — retired code (recoverable, not destroyed)
|
||||||
|
|
||||||
|
Per issue #75 (consolidate to a single harness), the routing-pod path was removed
|
||||||
|
from the live tree. It is **preserved and recoverable**, not deleted without trace.
|
||||||
|
|
||||||
|
## What was iceboxed (2026-07-01, issue #75)
|
||||||
|
|
||||||
|
| Path | Why |
|
||||||
|
|------|-----|
|
||||||
|
| `cmd/routing/` | The routing MCP-server binary; every skill call was wrapped through the broken pass-rate router (`wrap(skillName)`). |
|
||||||
|
| `internal/routing/` | Router / Fetcher / Policy / pass-rate. Signal is survivorship-biased and unusable as-is (infra#174). |
|
||||||
|
| `internal/skills/{review,debug,retrospective,trainer,project}/` | Skill handlers usable **only** through `cmd/routing` (verified: each imported solely by `cmd/routing`). |
|
||||||
|
| `Dockerfile.routing` | Built `cmd/routing` exclusively. |
|
||||||
|
| `.gitea/workflows/cd.yml` (routing steps only) | Removed the routing image build + infra image-bump + Flux-wait/rollout-verify for routing; **ingestion build/deploy is unchanged**. |
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The live minimal harness is `cmd/hyperguild` + `brain-mcp` (+ `gitea-mcp` available) —
|
||||||
|
that is what ran the infra#170 loop-1 experiment (routing/injection machinery off) and
|
||||||
|
closed it twice. `cmd/hyperguild` has **zero** transitive dependency on `internal/routing`
|
||||||
|
or `cmd/routing`'s packages (it imports only `internal/tier`). The routing pass-rate signal
|
||||||
|
is being retired, not resurrected (fresh start, per infra#174).
|
||||||
|
|
||||||
|
## How to recover
|
||||||
|
|
||||||
|
Everything above is preserved at commit `00e5f62` under:
|
||||||
|
|
||||||
|
- **tag** `icebox/cmd-routing-2026-07-01`
|
||||||
|
- **branch** `icebox/cmd-routing`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# inspect
|
||||||
|
git checkout icebox/cmd-routing-2026-07-01
|
||||||
|
|
||||||
|
# restore specific packages onto a branch
|
||||||
|
git checkout icebox/cmd-routing-2026-07-01 -- cmd/routing internal/routing \
|
||||||
|
internal/skills/review internal/skills/debug internal/skills/retrospective \
|
||||||
|
internal/skills/trainer internal/skills/project Dockerfile.routing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deliberately NOT touched here (separate scope)
|
||||||
|
|
||||||
|
- **Live k8s routing deployment** (`infra` repo, `k3s/apps/routing/`) still runs its last
|
||||||
|
image; CD no longer rebuilds/redeploys it. Tearing down that deployment is a separate
|
||||||
|
infra-repo task.
|
||||||
|
- **`internal/skills/{brain,org,sessionlog}/`** — kept per #75; already had no importer
|
||||||
|
(orphaned before this cut), harmless, compile + test green.
|
||||||
|
- **`config/supervisor/{review,debug,retrospective,trainer-*}.md`** — routing skill prompts,
|
||||||
|
now orphaned data; left in place (not code, no build impact).
|
||||||
@@ -5,14 +5,36 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
|
|||||||
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||||
into a searchable brain.
|
into a searchable brain.
|
||||||
|
|
||||||
|
**This repo is also where `brain-mcp` lives** — the MCP server at
|
||||||
|
`brain-mcp.d-ma.be` that `brain_query`, `brain_write`, `brain_read`, `brain_edit`
|
||||||
|
and `capture` are served from. It is not a separate deployable; see
|
||||||
|
[Where brain-mcp lives](#where-brain-mcp-lives).
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
> We believe routing skill tasks through local models, backed by brain context,
|
||||||
|
> produces measurably better outcomes than raw Claude Code alone —
|
||||||
|
> measurable by per-skill pass rate over rolling 30-day windows
|
||||||
|
> (available at `GET /pass-rate?skill=<name>&window=30d` on the brain pod).
|
||||||
|
|
||||||
|
This is the falsifiable claim the routing pod and pass-rate infrastructure exist to test.
|
||||||
|
If per-skill pass rates don't improve over baseline (all-cloud) after 30 days of real
|
||||||
|
usage, the fast-model routing path should be reconsidered.
|
||||||
|
|
||||||
|
## Harness
|
||||||
|
|
||||||
|
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
||||||
|
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
||||||
|
[agentsquad](https://git.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
||||||
|
orchestration model. Skills (mathias/skills) are shared between both.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
Your Claude Code session (in any project)
|
Your Claude Code session (in any project)
|
||||||
│
|
│
|
||||||
│ MCP over HTTP (Tailscale)
|
│ MCP over HTTP (Tailscale)
|
||||||
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
|
├──▶ routing :3210 (NodePort 30310 on koala) — review, debug, retrospective, trainer
|
||||||
├──▶ routing :3210 (NodePort 30310 on koala) — Mode 2 only: review, debug, retrospective, trainer
|
|
||||||
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
|
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
|
||||||
│
|
│
|
||||||
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
|
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
|
||||||
@@ -20,34 +42,55 @@ Your Claude Code session (in any project)
|
|||||||
▼
|
▼
|
||||||
brain/
|
brain/
|
||||||
├── sessions/ — JSONL log, one file per session_id
|
├── sessions/ — JSONL log, one file per session_id
|
||||||
├── wiki/ — searchable knowledge (full-text)
|
├── wiki/ — searchable knowledge (wing/hall layout)
|
||||||
│ ├── concepts/
|
│ ├── homelab/
|
||||||
│ ├── entities/
|
│ ├── claude-sessions/
|
||||||
│ └── sources/
|
│ └── ...
|
||||||
├── raw/ — retrospective output, staged for review
|
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||||
└── training-data/ — SFT/DPO/RL data (Phase 2)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Where brain-mcp lives
|
||||||
|
|
||||||
|
`brain-mcp` is served from **`ingestion/internal/mcp`** in this repo, at
|
||||||
|
**`brain-mcp.d-ma.be`**. It is a component *inside* the `ingestion` service, not
|
||||||
|
a standalone deployable — one pod in the `supervisor` namespace serves the MCP
|
||||||
|
endpoint, the REST API, and the claudewatcher together.
|
||||||
|
|
||||||
|
| You are looking for | It is here |
|
||||||
|
|---|---|
|
||||||
|
| The MCP tool surface (`brain_query`, `brain_write`, `brain_read`, `brain_edit`, `capture`, …) | `ingestion/internal/mcp` |
|
||||||
|
| The HTTP/REST API (`/query`, `/write`, `/ingest`) | `ingestion/internal/api` |
|
||||||
|
| Session ingestion from Claude Code transcripts | `ingestion/internal/claudewatcher` |
|
||||||
|
| The brain *content* (notes, wings, halls) | [`mathias/brain`](https://git.d-ma.be/mathias/brain) — a separate repo |
|
||||||
|
| Deployment manifests | `config/supervisor` |
|
||||||
|
|
||||||
|
### Why it is not its own repo
|
||||||
|
|
||||||
|
Asked and decided in [#91](https://git.d-ma.be/mathias/hyperguild/issues/91)
|
||||||
|
(part 2). `internal/mcp` imports 17 of ingestion's other internal packages and
|
||||||
|
has exactly one importer — it is the MCP facade over the service's own
|
||||||
|
internals, so "move it out" resolves to renaming `ingestion`, publishing 17
|
||||||
|
packages as a shared library, or maintaining a shim. The discoverability problem
|
||||||
|
that prompted the question is answered by naming it here instead.
|
||||||
|
|
||||||
|
Full reasoning, including the reversal trigger: brain
|
||||||
|
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
||||||
|
|
||||||
## Phase 1 tools (available now)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `tdd_red` | Writes a failing test for a spec, verifies it fails |
|
|
||||||
| `tdd_green` | Writes the minimal implementation to make tests pass |
|
|
||||||
| `tdd_refactor` | Cleans up implementation while keeping tests green |
|
|
||||||
| `session_log` | Appends a structured entry to the session JSONL log |
|
| `session_log` | Appends a structured entry to the session JSONL log |
|
||||||
| `retrospective` | Reads the session log, identifies novel learnings, writes to brain/raw/ |
|
| `retrospective` | Reads the session log, identifies novel learnings, writes to brain |
|
||||||
|
| `review` | Structured code review via local model, brain-context injected |
|
||||||
|
| `debug` | Hypothesis-driven debugging via local model |
|
||||||
| `brain_query` | Full-text search over brain/wiki/ |
|
| `brain_query` | Full-text search over brain/wiki/ |
|
||||||
| `brain_write` | Writes a note to brain/raw/ (with optional YAML frontmatter) |
|
| `brain_write` | Writes a note to brain (with wing/hall routing) |
|
||||||
|
| `brain_answer` | BM25 + LLM synthesis — Q&A over brain corpus |
|
||||||
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||||
|
|
||||||
## Start the servers
|
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
||||||
|
> They are now SKILL.md files in [mathias/skills](https://git.d-ma.be/mathias/skills).
|
||||||
```bash
|
|
||||||
# Requires goreman: go install github.com/mattn/goreman@latest
|
|
||||||
task start # starts ingestion (:3300) + supervisor (:3200) via goreman
|
|
||||||
task stop # kills both by port
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connect a project
|
## Connect a project
|
||||||
|
|
||||||
@@ -56,9 +99,9 @@ Create `.mcp.json` in your project root:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"supervisor": {
|
"routing": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://koala:30320/mcp"
|
"url": "http://koala:30310/mcp"
|
||||||
},
|
},
|
||||||
"brain": {
|
"brain": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
@@ -68,33 +111,29 @@ Create `.mcp.json` in your project root:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Two MCP servers are exposed today, both reachable over Tailscale:
|
Two MCP servers are exposed, both reachable over Tailscale:
|
||||||
|
|
||||||
- **`supervisor`** at `koala:30320` — skill workers (`tdd_red/green/refactor`,
|
- **`routing`** at `koala:30310` — skill workers (`review`, `debug`, `retrospective`, `trainer`).
|
||||||
`review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`).
|
Routes each call to fast local model or thinking model based on per-skill pass rate.
|
||||||
- **`brain`** at `koala:30330` — knowledge access (`brain_query`, `brain_write`,
|
- **`brain`** at `koala:30330` — knowledge access (`brain_query`, `brain_write`,
|
||||||
`brain_ingest`, `brain_ingest_raw`) and `session_log`. Hosted by the ingestion
|
`brain_ingest`, `brain_ingest_raw`, `brain_answer`, `brain_classify`) and `session_log`.
|
||||||
service directly, no separate pod.
|
|
||||||
|
|
||||||
No local binary or stdio shim is required — Claude Code talks to both via HTTP.
|
No local binary or stdio shim is required — Claude Code talks to both via HTTP.
|
||||||
|
|
||||||
Open Claude Code in your project — run `/mcp` to confirm both servers are listed.
|
Open Claude Code in your project — run `/mcp` to confirm both servers are listed.
|
||||||
|
|
||||||
## A typical TDD session
|
## A typical session
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Call tdd_red → spec in, failing test file out
|
1. Call review → brain context injected + local model review → findings
|
||||||
2. Call tdd_green → test path in, implementation out
|
2. Call session_log → log each phase result
|
||||||
3. Call tdd_refactor → impl + test in, cleaned code out
|
3. Call retrospective → extracts learnings → brain
|
||||||
4. Call session_log → log each phase result
|
4. Future sessions: call brain_query / brain_answer to retrieve relevant context
|
||||||
5. Call retrospective → extracts learnings → brain/raw/
|
|
||||||
6. Review brain/raw/, move worthy notes to brain/wiki/concepts/
|
|
||||||
7. Future sessions: call brain_query to retrieve relevant context
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tier detection
|
## Tier detection
|
||||||
|
|
||||||
The supervisor probes connectivity at call time:
|
The routing pod probes connectivity at call time:
|
||||||
|
|
||||||
| Tier | Label | Condition |
|
| Tier | Label | Condition |
|
||||||
|------|-------|-----------|
|
|------|-------|-----------|
|
||||||
@@ -102,31 +141,47 @@ The supervisor probes connectivity at call time:
|
|||||||
| 2 | lan-only | Can reach LiteLLM but not Anthropic |
|
| 2 | lan-only | Can reach LiteLLM but not Anthropic |
|
||||||
| 3 | airplane | No external connectivity |
|
| 3 | airplane | No external connectivity |
|
||||||
|
|
||||||
|
## Model routing
|
||||||
|
|
||||||
|
The routing pod selects models per skill call based on historical pass rate:
|
||||||
|
|
||||||
|
| Pass rate | Decision |
|
||||||
|
|-----------|----------|
|
||||||
|
| ≥ 0.90 (FLOOR) | Fast model (`HYPERGUILD_FAST_MODEL`) |
|
||||||
|
| ≤ 0.70 (CEIL) | Thinking model (`HYPERGUILD_THINKING_MODEL`) |
|
||||||
|
| between CEIL and FLOOR | Sample band — probabilistic routing |
|
||||||
|
| nil (no history yet) | Defaults to thinking model |
|
||||||
|
|
||||||
|
> **Bootstrap note:** With no session history, all calls route to the thinking model.
|
||||||
|
> The fast-model path activates only after real pass-rate data accumulates at `/pass-rate`.
|
||||||
|
> Seed with real usage — don't try to pre-populate.
|
||||||
|
|
||||||
## Key env vars
|
## Key env vars
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `INGEST_BRAIN_DIR` | `../brain` | Brain directory for ingestion server |
|
| `INGEST_BRAIN_DIR` | `../brain` | Brain directory for ingestion server |
|
||||||
| `INGEST_PORT` | `3300` | Ingestion server port |
|
| `INGEST_PORT` | `3300` | Ingestion server port |
|
||||||
| `SUPERVISOR_CONFIG_DIR` | `./config/supervisor` | Skill discipline files |
|
| `INGEST_BASE_URL` | `http://localhost:3300` | Routing pod → brain |
|
||||||
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
|
||||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
|
||||||
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
|
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
|
||||||
| `SUPERVISOR_MCP_TOKEN` | — | Optional bearer token for the supervisor MCP HTTP endpoint; when empty, no auth is enforced |
|
|
||||||
| `ROUTING_PORT` | `3210` | Routing pod's listen port |
|
| `ROUTING_PORT` | `3210` | Routing pod's listen port |
|
||||||
| `ROUTING_MCP_TOKEN` | — | Optional bearer token for the routing MCP HTTP endpoint |
|
| `ROUTING_MCP_TOKEN` | — | Optional bearer token; when empty, no auth enforced |
|
||||||
| `BRAIN_URL` | `http://ingestion.supervisor:3300` | Routing pod → brain (in-cluster) |
|
| `BRAIN_URL` | `http://ingestion.supervisor:3300` | Routing pod → brain (in-cluster) |
|
||||||
| `HYPERGUILD_FAST_MODEL` | `koala/qwen35-9b-fast` | Fast model for high-pass-rate skill calls |
|
| `HYPERGUILD_FAST_MODEL` | `koala/qwen35-9b-fast` | Fast model for high-pass-rate skill calls |
|
||||||
| `HYPERGUILD_THINKING_MODEL` | `iguana/gemma4-26b` | Thinking model for low-pass-rate skill calls |
|
| `HYPERGUILD_THINKING_MODEL` | `iguana/gemma4-26b` | Thinking model for low-pass-rate skill calls |
|
||||||
| `HYPERGUILD_ROUTE_LOCAL_FLOOR` | `0.90` | At/above pass rate, route to fast model |
|
| `HYPERGUILD_ROUTE_LOCAL_FLOOR` | `0.90` | Fast model threshold |
|
||||||
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below pass rate, route to thinking model. Between CEIL and FLOOR is the sample band. |
|
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Thinking model threshold |
|
||||||
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill pass-rate cache TTL |
|
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill pass-rate cache TTL |
|
||||||
|
|
||||||
> **Operator note:** LiteLLM at `LITELLM_BASE_URL` must register both `HYPERGUILD_FAST_MODEL` and `HYPERGUILD_THINKING_MODEL` for routing to do useful work. If a model is missing, LiteLLM returns 4xx, the routing pod's fast route fails, the fail-open retry on the thinking model likely also fails (since both are missing), and the only signal is `final_status: "fail"` on `_routing` entries in the brain.
|
> **Operator note:** LiteLLM at `LITELLM_BASE_URL` must register both `HYPERGUILD_FAST_MODEL`
|
||||||
|
> and `HYPERGUILD_THINKING_MODEL`. If a model is missing, the fail-open retry also fails and
|
||||||
|
> the only signal is `final_status: "fail"` on `_routing` entries in the brain.
|
||||||
|
|
||||||
## Phase 2 (planned)
|
## Open issues
|
||||||
|
|
||||||
- `review` skill — structured code review with iron law enforcement
|
See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||||
- `debug` skill — hypothesis-driven debugging sessions
|
|
||||||
- `spec` skill — generates specs from conversations
|
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||||
- `trainer` — extracts SFT/DPO pairs from session logs for fine-tuning
|
- **#24** — reduce context burn from skill listing
|
||||||
|
- **#22** — migrate legacy brain notes to wing/hall layout (one-shot script, low risk)
|
||||||
|
- **#31** — connect routing-mcp to claude.ai as custom connector
|
||||||
|
|||||||
+58
-5
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -88,27 +86,82 @@ tasks:
|
|||||||
check:
|
check:
|
||||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
- task: context:sync
|
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
||||||
|
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
||||||
|
# the tree for the canonical .context/AGENT.md, which lives in local-dev
|
||||||
|
# and does not exist on a CI runner. Without this guard, the script
|
||||||
|
# silently falls back to "project context only" and regenerates AGENTS.md
|
||||||
|
# and .context/system-prompt.txt shorter than their committed, correct
|
||||||
|
# versions -- so the drift assertion fails every CI run for a purely
|
||||||
|
# environmental reason, not real drift. Verified, not assumed: this is
|
||||||
|
# exactly what was happening here (AGENTS.md 430 lines committed vs. an
|
||||||
|
# 82-line regeneration on the runner).
|
||||||
|
#
|
||||||
|
# The cost is real and worth stating: drift is therefore only ever caught
|
||||||
|
# on a developer machine, or by infra's estate-wide context-sync-drift
|
||||||
|
# CronJob (ADR-0019), which does have canonical access and is the actual
|
||||||
|
# enforcement mechanism now.
|
||||||
- cmd: |
|
- cmd: |
|
||||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
if [ -n "${CI:-}" ]; then
|
||||||
|
echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
|
||||||
|
else
|
||||||
|
bash scripts/context-sync.sh
|
||||||
|
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
|
||||||
echo "✓ context: canonical and adapters are in sync"
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
|
fi
|
||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
|
- task: security:gitleaks
|
||||||
|
|
||||||
|
# ── Security ─────────────────────────────────────────────
|
||||||
|
security:gitleaks:
|
||||||
|
desc: Scan the working tree for secrets (gitleaks, fail-closed; skipped if gitleaks absent)
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "⚠ gitleaks not installed — skipping secret scan (CI enforces it)."
|
||||||
|
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
"$GL" detect --no-git --redact --config .gitleaks.toml --source .
|
||||||
|
|
||||||
|
security:gitleaks:history:
|
||||||
|
desc: "One-time FULL-HISTORY secret audit (infra#39 rotation pass; not a per-push gate)"
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "gitleaks not installed: go install github.com/zricethezav/gitleaks/v8@latest" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
echo "Scanning FULL git history (redacted). Known historical leaks are expected"
|
||||||
|
echo "until the infra#39 rotation pass completes — triage against the rotation list."
|
||||||
|
"$GL" detect --redact --config .gitleaks.toml
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run ./...
|
- golangci-lint run ./...
|
||||||
- cd ingestion && golangci-lint run ./...
|
- cd ingestion && golangci-lint run ./...
|
||||||
|
|
||||||
|
lint:frontmatter:
|
||||||
|
desc: Report brain notes whose YAML frontmatter does not parse (BRAIN=path, FIX=1 repairs in place)
|
||||||
|
cmds:
|
||||||
|
- cd ingestion && go run ./cmd/frontmatterlint {{if .FIX}}-fix{{end}} {{.BRAIN | default "../../brain"}}
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cmds:
|
cmds:
|
||||||
- go test -race -count=1 ./...
|
- go test -race -count=1 ./...
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# baseline-pre-fix — 20 questions, k=5
|
||||||
|
|
||||||
|
top-1 hit rate: 4/20 = 20%
|
||||||
|
top-3 hit rate: 13/20 = 65%
|
||||||
|
|
||||||
|
## per-question detail
|
||||||
|
|
||||||
|
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
q: how do I stop dex from logging users out on every pod restart?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
|
||||||
|
★ rank=1 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||||
|
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||||
|
1. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. brain-mcp-activation-runbook
|
||||||
|
4. extension-version-lags-platform-major-upgrade
|
||||||
|
5. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-network-perimeter-model
|
||||||
|
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||||
|
1. homelab-network-perimeter-model <-- expected
|
||||||
|
2. qwen3-thinking-model-empty-content-trap
|
||||||
|
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||||
|
q: what does container exit code 255 with reason Unknown mean?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. exit-255-unknown-reason-not-oom <-- expected
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
q: can gitea push-mirror create the github repo automatically?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. Autoresearch
|
||||||
|
3. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||||
|
4. adr-new-project-gitea-first-github-mirror
|
||||||
|
5. adr-github-as-primary-remote
|
||||||
|
|
||||||
|
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||||
|
q: a flux kustomization is stuck after I removed a resource — why?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. homelab-architecture-principles-2026-05
|
||||||
|
4. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||||
|
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
|
||||||
|
· rank=2 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||||
|
1. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
2. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||||
|
3. homelab-security-chains-not-bugs
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. Hash Encoding
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||||
|
q: what are the homelab architecture principles from may 2026?
|
||||||
|
1. homelab-architecture-principles-2026-05 <-- expected
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||||
|
4. homelab-core-glossary
|
||||||
|
5. 2026-05-12-koala-machine-state
|
||||||
|
|
||||||
|
✗ rank=0 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||||
|
q: where does the sops age private key live in the cluster?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||||
|
4. brain-mcp-activation-runbook
|
||||||
|
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
|
||||||
|
✗ rank=0 expected=grafana-dashboards-as-code-not-ui-state
|
||||||
|
q: why do my grafana dashboards disappear after a pod restart?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
4. brain-mcp-activation-runbook
|
||||||
|
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
|
||||||
|
· rank=2 expected=double-diamond-methodology
|
||||||
|
q: what is the double diamond methodology?
|
||||||
|
1. Harnessing the Power of Hash Encoding for Categorical Data in Data Science
|
||||||
|
2. double-diamond-methodology <-- expected
|
||||||
|
3. unified-methodology-diamond-futures-autoresearch
|
||||||
|
4. futures-thinking-extended-double-diamond
|
||||||
|
5. insight-exploration-as-diamond-1
|
||||||
|
|
||||||
|
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||||
|
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||||
|
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. finding-github-mcp-claudeai-vs-claudecode
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-security-chains-not-bugs
|
||||||
|
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. homelab-security-chains-not-bugs <-- expected
|
||||||
|
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
4. policy-audit-mode-blocks-nothing
|
||||||
|
5. homelab-document-accepted-risk-to-break-audit-cycle
|
||||||
|
|
||||||
|
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||||
|
q: how should canonical context files relate to derived adapter files?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-core-glossary
|
||||||
|
q: what is the homelab core vocabulary glossary?
|
||||||
|
1. homelab-architecture-principles-2026-05
|
||||||
|
2. homelab-core-glossary <-- expected
|
||||||
|
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. Autoresearch
|
||||||
|
|
||||||
|
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||||
|
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. infra-litellm-absorption-2026-05-16
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. qwen3-thinking-model-empty-content-trap
|
||||||
|
|
||||||
|
✗ rank=0 expected=qwen35-9b-fast
|
||||||
|
q: what is qwen35-9b-fast and what's it used for?
|
||||||
|
1. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
2. qwen3-thinking-model-empty-content-trap
|
||||||
|
3. Qwen35-9b-fast
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. 2026-05-12-koala-machine-state
|
||||||
|
|
||||||
|
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||||
|
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. brain-mcp-activation-runbook
|
||||||
|
|
||||||
|
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||||
|
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-core-glossary
|
||||||
|
3. brain-mcp-activation-runbook
|
||||||
|
4. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
5. infra-litellm-absorption-2026-05-16
|
||||||
|
|
||||||
|
? rank=4 expected=adr-new-project-gitea-first-github-mirror
|
||||||
|
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||||
|
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
2. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||||
|
3. mcp-tool-design-get-needs-list-partner
|
||||||
|
4. adr-new-project-gitea-first-github-mirror <-- expected
|
||||||
|
5. 2026-05-04-gitea-mcp-build-session
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# post-fix — 20 questions, k=5
|
||||||
|
|
||||||
|
top-1 hit rate: 4/20 = 20%
|
||||||
|
top-3 hit rate: 14/20 = 70%
|
||||||
|
|
||||||
|
## per-question detail
|
||||||
|
|
||||||
|
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
q: how do I stop dex from logging users out on every pod restart?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
|
||||||
|
★ rank=1 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||||
|
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||||
|
1. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. brain-mcp-activation-runbook
|
||||||
|
4. extension-version-lags-platform-major-upgrade
|
||||||
|
5. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-network-perimeter-model
|
||||||
|
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||||
|
1. homelab-network-perimeter-model <-- expected
|
||||||
|
2. qwen3-thinking-model-empty-content-trap
|
||||||
|
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||||
|
q: what does container exit code 255 with reason Unknown mean?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. exit-255-unknown-reason-not-oom <-- expected
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
q: can gitea push-mirror create the github repo automatically?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. Autoresearch
|
||||||
|
3. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||||
|
4. adr-new-project-gitea-first-github-mirror
|
||||||
|
5. adr-github-as-primary-remote
|
||||||
|
|
||||||
|
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||||
|
q: a flux kustomization is stuck after I removed a resource — why?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. homelab-architecture-principles-2026-05
|
||||||
|
4. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||||
|
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
|
||||||
|
· rank=2 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||||
|
1. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
2. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||||
|
3. homelab-security-chains-not-bugs
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. Hash Encoding
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||||
|
q: what are the homelab architecture principles from may 2026?
|
||||||
|
1. homelab-architecture-principles-2026-05 <-- expected
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||||
|
4. homelab-core-glossary
|
||||||
|
5. 2026-05-12-koala-machine-state
|
||||||
|
|
||||||
|
✗ rank=0 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||||
|
q: where does the sops age private key live in the cluster?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||||
|
4. brain-mcp-activation-runbook
|
||||||
|
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
|
||||||
|
✗ rank=0 expected=grafana-dashboards-as-code-not-ui-state
|
||||||
|
q: why do my grafana dashboards disappear after a pod restart?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
4. brain-mcp-activation-runbook
|
||||||
|
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
|
||||||
|
· rank=2 expected=double-diamond-methodology
|
||||||
|
q: what is the double diamond methodology?
|
||||||
|
1. Harnessing the Power of Hash Encoding for Categorical Data in Data Science
|
||||||
|
2. double-diamond-methodology <-- expected
|
||||||
|
3. unified-methodology-diamond-futures-autoresearch
|
||||||
|
4. futures-thinking-extended-double-diamond
|
||||||
|
5. insight-exploration-as-diamond-1
|
||||||
|
|
||||||
|
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||||
|
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||||
|
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. finding-github-mcp-claudeai-vs-claudecode
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-security-chains-not-bugs
|
||||||
|
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. homelab-security-chains-not-bugs <-- expected
|
||||||
|
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
4. policy-audit-mode-blocks-nothing
|
||||||
|
5. homelab-document-accepted-risk-to-break-audit-cycle
|
||||||
|
|
||||||
|
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||||
|
q: how should canonical context files relate to derived adapter files?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-core-glossary
|
||||||
|
q: what is the homelab core vocabulary glossary?
|
||||||
|
1. homelab-architecture-principles-2026-05
|
||||||
|
2. homelab-core-glossary <-- expected
|
||||||
|
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. Autoresearch
|
||||||
|
|
||||||
|
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||||
|
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. infra-litellm-absorption-2026-05-16
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. qwen3-thinking-model-empty-content-trap
|
||||||
|
|
||||||
|
· rank=2 expected=qwen35-9b-fast
|
||||||
|
q: what is qwen35-9b-fast and what's it used for?
|
||||||
|
1. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
2. qwen35-9b-fast <-- expected
|
||||||
|
3. qwen3-thinking-model-empty-content-trap
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. 2026-05-12-koala-machine-state
|
||||||
|
|
||||||
|
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||||
|
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. brain-mcp-activation-runbook
|
||||||
|
|
||||||
|
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||||
|
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-core-glossary
|
||||||
|
3. brain-mcp-activation-runbook
|
||||||
|
4. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
5. infra-litellm-absorption-2026-05-16
|
||||||
|
|
||||||
|
? rank=4 expected=adr-new-project-gitea-first-github-mirror
|
||||||
|
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||||
|
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
2. gitea-mcp: full stack shipped end-to-end (2026-05-05)
|
||||||
|
3. mcp-tool-design-get-needs-list-partner
|
||||||
|
4. adr-new-project-gitea-first-github-mirror <-- expected
|
||||||
|
5. 2026-05-04-gitea-mcp-build-session
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# post-m4-tier-weighting — 20 questions, k=5
|
||||||
|
|
||||||
|
top-1 hit rate: 6/20 = 30%
|
||||||
|
top-3 hit rate: 15/20 = 75%
|
||||||
|
|
||||||
|
## per-question detail
|
||||||
|
|
||||||
|
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
q: how do I stop dex from logging users out on every pod restart?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
|
||||||
|
· rank=2 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||||
|
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||||
|
3. extension-version-lags-platform-major-upgrade
|
||||||
|
4. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||||
|
5. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-network-perimeter-model
|
||||||
|
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||||
|
1. homelab-network-perimeter-model <-- expected
|
||||||
|
2. qwen3-thinking-model-empty-content-trap
|
||||||
|
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||||
|
q: what does container exit code 255 with reason Unknown mean?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. exit-255-unknown-reason-not-oom <-- expected
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=2 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
q: can gitea push-mirror create the github repo automatically?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||||
|
3. adr-new-project-gitea-first-github-mirror
|
||||||
|
4. adr-github-as-primary-remote
|
||||||
|
5. 2026-05-12-koala-machine-state
|
||||||
|
|
||||||
|
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||||
|
q: a flux kustomization is stuck after I removed a resource — why?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. homelab-architecture-principles-2026-05
|
||||||
|
4. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
5. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
|
||||||
|
★ rank=1 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||||
|
1. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||||
|
2. homelab-security-chains-not-bugs
|
||||||
|
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. flux-healthcheck-stale-on-resource-removal
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||||
|
q: what are the homelab architecture principles from may 2026?
|
||||||
|
1. homelab-architecture-principles-2026-05 <-- expected
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. homelab-core-glossary
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. pattern-reddit-tmux-multiagent-conductor
|
||||||
|
|
||||||
|
? rank=4 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||||
|
q: where does the sops age private key live in the cluster?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
4. 2026-05-04-sops-age-key-from-flux-cluster <-- expected
|
||||||
|
5. homelab-security-chains-not-bugs
|
||||||
|
|
||||||
|
★ rank=1 expected=grafana-dashboards-as-code-not-ui-state
|
||||||
|
q: why do my grafana dashboards disappear after a pod restart?
|
||||||
|
1. grafana-dashboards-as-code-not-ui-state <-- expected
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
|
||||||
|
★ rank=1 expected=double-diamond-methodology
|
||||||
|
q: what is the double diamond methodology?
|
||||||
|
1. double-diamond-methodology <-- expected
|
||||||
|
2. unified-methodology-diamond-futures-autoresearch
|
||||||
|
3. futures-thinking-extended-double-diamond
|
||||||
|
4. insight-exploration-as-diamond-1
|
||||||
|
5. workflow-idea-to-running-service
|
||||||
|
|
||||||
|
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||||
|
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||||
|
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. finding-github-mcp-claudeai-vs-claudecode
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-security-chains-not-bugs
|
||||||
|
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. homelab-security-chains-not-bugs <-- expected
|
||||||
|
3. policy-audit-mode-blocks-nothing
|
||||||
|
4. homelab-document-accepted-risk-to-break-audit-cycle
|
||||||
|
5. audit-shortcut-tls-blocks-zero-equals-edge-only
|
||||||
|
|
||||||
|
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||||
|
q: how should canonical context files relate to derived adapter files?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-core-glossary
|
||||||
|
q: what is the homelab core vocabulary glossary?
|
||||||
|
1. homelab-architecture-principles-2026-05
|
||||||
|
2. homelab-core-glossary <-- expected
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. flux-kustomization-depends-on-bootstrap-ordering
|
||||||
|
5. brain-ingest-ntfy-service
|
||||||
|
|
||||||
|
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||||
|
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. infra-litellm-absorption-2026-05-16
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. qwen3-thinking-model-empty-content-trap
|
||||||
|
|
||||||
|
✗ rank=0 expected=qwen35-9b-fast
|
||||||
|
q: what is qwen35-9b-fast and what's it used for?
|
||||||
|
1. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
2. qwen3-thinking-model-empty-content-trap
|
||||||
|
3. infra-litellm-absorption-2026-05-16
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. index
|
||||||
|
|
||||||
|
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||||
|
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||||
|
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-core-glossary
|
||||||
|
3. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. homelab-architecture-principles-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=adr-new-project-gitea-first-github-mirror
|
||||||
|
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||||
|
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
2. mcp-tool-design-get-needs-list-partner
|
||||||
|
3. adr-new-project-gitea-first-github-mirror <-- expected
|
||||||
|
4. 2026-05-04-gitea-mcp-build-session
|
||||||
|
5. adr-local-dev-vs-hyperguild-new-project
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# post-m4b-entities-promoted — 20 questions, k=5
|
||||||
|
|
||||||
|
top-1 hit rate: 7/20 = 35%
|
||||||
|
top-3 hit rate: 16/20 = 80%
|
||||||
|
|
||||||
|
## per-question detail
|
||||||
|
|
||||||
|
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
q: how do I stop dex from logging users out on every pod restart?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
|
||||||
|
· rank=2 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||||
|
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
|
||||||
|
3. extension-version-lags-platform-major-upgrade
|
||||||
|
4. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
|
||||||
|
5. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-network-perimeter-model
|
||||||
|
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||||
|
1. homelab-network-perimeter-model <-- expected
|
||||||
|
2. qwen3-thinking-model-empty-content-trap
|
||||||
|
3. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=exit-255-unknown-reason-not-oom
|
||||||
|
q: what does container exit code 255 with reason Unknown mean?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. exit-255-unknown-reason-not-oom <-- expected
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=2 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
q: can gitea push-mirror create the github repo automatically?
|
||||||
|
1. infra-litellm-absorption-2026-05-16
|
||||||
|
2. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
|
||||||
|
3. adr-new-project-gitea-first-github-mirror
|
||||||
|
4. adr-github-as-primary-remote
|
||||||
|
5. 2026-05-12-koala-machine-state
|
||||||
|
|
||||||
|
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
|
||||||
|
q: a flux kustomization is stuck after I removed a resource — why?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. homelab-architecture-principles-2026-05
|
||||||
|
4. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
5. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
|
||||||
|
★ rank=1 expected=go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||||
|
1. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
|
||||||
|
2. homelab-security-chains-not-bugs
|
||||||
|
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. flux-healthcheck-stale-on-resource-removal
|
||||||
|
|
||||||
|
★ rank=1 expected=homelab-architecture-principles-2026-05
|
||||||
|
q: what are the homelab architecture principles from may 2026?
|
||||||
|
1. homelab-architecture-principles-2026-05 <-- expected
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. homelab-core-glossary
|
||||||
|
4. 2026-05-12-koala-machine-state
|
||||||
|
5. pattern-reddit-tmux-multiagent-conductor
|
||||||
|
|
||||||
|
? rank=4 expected=2026-05-04-sops-age-key-from-flux-cluster
|
||||||
|
q: where does the sops age private key live in the cluster?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-network-perimeter-model
|
||||||
|
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
4. 2026-05-04-sops-age-key-from-flux-cluster <-- expected
|
||||||
|
5. homelab-security-chains-not-bugs
|
||||||
|
|
||||||
|
★ rank=1 expected=grafana-dashboards-as-code-not-ui-state
|
||||||
|
q: why do my grafana dashboards disappear after a pod restart?
|
||||||
|
1. grafana-dashboards-as-code-not-ui-state <-- expected
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
5. k8s-configmap-mount-no-reload-needs-pod-restart
|
||||||
|
|
||||||
|
★ rank=1 expected=double-diamond-methodology
|
||||||
|
q: what is the double diamond methodology?
|
||||||
|
1. double-diamond-methodology <-- expected
|
||||||
|
2. unified-methodology-diamond-futures-autoresearch
|
||||||
|
3. futures-thinking-extended-double-diamond
|
||||||
|
4. insight-exploration-as-diamond-1
|
||||||
|
5. workflow-idea-to-running-service
|
||||||
|
|
||||||
|
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
|
||||||
|
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
|
||||||
|
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. finding-github-mcp-claudeai-vs-claudecode
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-security-chains-not-bugs
|
||||||
|
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. homelab-security-chains-not-bugs <-- expected
|
||||||
|
3. policy-audit-mode-blocks-nothing
|
||||||
|
4. homelab-document-accepted-risk-to-break-audit-cycle
|
||||||
|
5. audit-shortcut-tls-blocks-zero-equals-edge-only
|
||||||
|
|
||||||
|
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
|
||||||
|
q: how should canonical context files relate to derived adapter files?
|
||||||
|
1. qwen3-thinking-model-empty-content-trap
|
||||||
|
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. 2026-05-04-claude-ai-custom-mcp-connectors
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
· rank=2 expected=homelab-core-glossary
|
||||||
|
q: what is the homelab core vocabulary glossary?
|
||||||
|
1. homelab-architecture-principles-2026-05
|
||||||
|
2. homelab-core-glossary <-- expected
|
||||||
|
3. 2026-05-12-koala-machine-state
|
||||||
|
4. qwen35-9b-fast
|
||||||
|
5. flux-kustomization-depends-on-bootstrap-ordering
|
||||||
|
|
||||||
|
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||||
|
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
|
||||||
|
2. 2026-05-12-koala-machine-state
|
||||||
|
3. infra-litellm-absorption-2026-05-16
|
||||||
|
4. training-on-rtx-5070-pretraining-vs-finetuning
|
||||||
|
5. qwen3-thinking-model-empty-content-trap
|
||||||
|
|
||||||
|
★ rank=1 expected=qwen35-9b-fast
|
||||||
|
q: what is qwen35-9b-fast and what's it used for?
|
||||||
|
1. qwen35-9b-fast <-- expected
|
||||||
|
2. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
3. qwen3-thinking-model-empty-content-trap
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. 2026-05-12-koala-machine-state
|
||||||
|
|
||||||
|
✗ rank=0 expected=go-defer-errcheck-body-close
|
||||||
|
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||||
|
1. homelab-network-perimeter-model
|
||||||
|
2. infra-litellm-absorption-2026-05-16
|
||||||
|
3. go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
4. mcpclient-empty-token-silent-401-envfrom-missing-key
|
||||||
|
5. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
|
||||||
|
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||||
|
1. 2026-05-12-koala-machine-state
|
||||||
|
2. homelab-core-glossary
|
||||||
|
3. koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
4. infra-litellm-absorption-2026-05-16
|
||||||
|
5. homelab-architecture-principles-2026-05
|
||||||
|
|
||||||
|
· rank=3 expected=adr-new-project-gitea-first-github-mirror
|
||||||
|
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||||
|
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
2. mcp-tool-design-get-needs-list-partner
|
||||||
|
3. adr-new-project-gitea-first-github-mirror <-- expected
|
||||||
|
4. 2026-05-04-gitea-mcp-build-session
|
||||||
|
5. adr-local-dev-vs-hyperguild-new-project
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Brain retrieval eval set — 2026-05-24
|
||||||
|
|
||||||
|
20 hand-authored Q→expected-top-1-slug pairs. Used by `score.sh` to
|
||||||
|
measure brain_query top-1 + top-3 hit rate against the live brain.
|
||||||
|
|
||||||
|
Authoring rules:
|
||||||
|
- Each question maps to **one** clear-best entry. Avoid ambiguous
|
||||||
|
questions where multiple slugs could be the right answer.
|
||||||
|
- Questions are phrased the way a future-me would actually ask, not
|
||||||
|
the way the entry's title reads. Some lexical distance is the point.
|
||||||
|
- `expected` is the slug as stored in `brain_entities.slug`. Update
|
||||||
|
if the slug renames.
|
||||||
|
|
||||||
|
## Pairs
|
||||||
|
|
||||||
|
```
|
||||||
|
q: how do I stop dex from logging users out on every pod restart?
|
||||||
|
expected: dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
|
||||||
|
|
||||||
|
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
|
||||||
|
expected: postgres-least-privilege-migration-tenant-grant-bypass-2026-05
|
||||||
|
|
||||||
|
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
|
||||||
|
expected: homelab-network-perimeter-model
|
||||||
|
|
||||||
|
q: what does container exit code 255 with reason Unknown mean?
|
||||||
|
expected: exit-255-unknown-reason-not-oom
|
||||||
|
|
||||||
|
q: can gitea push-mirror create the github repo automatically?
|
||||||
|
expected: gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
|
||||||
|
|
||||||
|
q: a flux kustomization is stuck after I removed a resource — why?
|
||||||
|
expected: flux-healthcheck-stale-on-resource-removal
|
||||||
|
|
||||||
|
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
|
||||||
|
expected: go-bytes-buffer-bytes-reset-aliasing-trap
|
||||||
|
|
||||||
|
q: what are the homelab architecture principles from may 2026?
|
||||||
|
expected: homelab-architecture-principles-2026-05
|
||||||
|
|
||||||
|
q: where does the sops age private key live in the cluster?
|
||||||
|
expected: 2026-05-04-sops-age-key-from-flux-cluster
|
||||||
|
|
||||||
|
q: why do my grafana dashboards disappear after a pod restart?
|
||||||
|
expected: grafana-dashboards-as-code-not-ui-state
|
||||||
|
|
||||||
|
q: what is the double diamond methodology?
|
||||||
|
expected: double-diamond-methodology
|
||||||
|
|
||||||
|
q: my MCP server works from claude code but fails on claude.ai — what's different?
|
||||||
|
expected: 2026-05-04-mcp-transport-version-claude-ai-strict
|
||||||
|
|
||||||
|
q: how should I rate security findings — isolated bugs or exploit chains?
|
||||||
|
expected: homelab-security-chains-not-bugs
|
||||||
|
|
||||||
|
q: how should canonical context files relate to derived adapter files?
|
||||||
|
expected: 2026-05-03-canonical-vs-derived-context-flow
|
||||||
|
|
||||||
|
q: what is the homelab core vocabulary glossary?
|
||||||
|
expected: homelab-core-glossary
|
||||||
|
|
||||||
|
q: which models on koala llama-swap actually emit native tool_calls correctly?
|
||||||
|
expected: koala-llama-swap-native-tool-calls-survey-2026-05
|
||||||
|
|
||||||
|
q: what is qwen35-9b-fast and what's it used for?
|
||||||
|
expected: qwen35-9b-fast
|
||||||
|
|
||||||
|
q: in go, how do I prevent defer body close from silently dropping errors?
|
||||||
|
expected: go-defer-errcheck-body-close
|
||||||
|
|
||||||
|
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
|
||||||
|
expected: hyperguild-level3-pipeline-rewrite
|
||||||
|
|
||||||
|
q: what's the new-project ADR — is it gitea-first or github-first?
|
||||||
|
expected: adr-new-project-gitea-first-github-mirror
|
||||||
|
```
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Score brain_query against the qa-2026-05.md eval set.
|
||||||
|
|
||||||
|
Reads `q:` / `expected:` pairs, calls brain_query MCP for each, records
|
||||||
|
top-1 + top-3 hit rate. Run:
|
||||||
|
|
||||||
|
BRAIN_MCP_TOKEN=$(grep '^export BRAIN_MCP_TOKEN=' ~/.llmkeys | cut -d= -f2-) \\
|
||||||
|
python3 score.py qa-2026-05.md
|
||||||
|
|
||||||
|
Optionally pass --baseline <name> to save the result as a labeled run.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ENDPOINT = "https://brain-mcp.d-ma.be/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def load_pairs(path):
|
||||||
|
pairs = []
|
||||||
|
q = None
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.rstrip()
|
||||||
|
if line.startswith("q:"):
|
||||||
|
q = line[2:].strip()
|
||||||
|
elif line.startswith("expected:") and q is not None:
|
||||||
|
expected = line[len("expected:"):].strip()
|
||||||
|
pairs.append((q, expected))
|
||||||
|
q = None
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def brain_query(token, query, k=5):
|
||||||
|
body = json.dumps({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {"name": "brain_query", "arguments": {"query": query, "k": k}},
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
ENDPOINT,
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
raw = r.read().decode()
|
||||||
|
for line in raw.splitlines():
|
||||||
|
if line.startswith("data:"):
|
||||||
|
raw = line[5:].strip()
|
||||||
|
break
|
||||||
|
d = json.loads(raw)
|
||||||
|
if "error" in d:
|
||||||
|
raise RuntimeError(d["error"])
|
||||||
|
text = d["result"]["content"][0]["text"]
|
||||||
|
return json.loads(text).get("results", [])
|
||||||
|
|
||||||
|
|
||||||
|
def slug_of(result):
|
||||||
|
# `title` mirrors the slug in brain_entities for normal entries.
|
||||||
|
# Fall back to basename(path) if title is missing.
|
||||||
|
t = result.get("title", "")
|
||||||
|
if t:
|
||||||
|
return t
|
||||||
|
p = result.get("path", "")
|
||||||
|
return re.sub(r"\.md$", "", os.path.basename(p))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("evalset")
|
||||||
|
ap.add_argument("--baseline", default="run")
|
||||||
|
ap.add_argument("--k", type=int, default=5)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
token = os.environ.get("BRAIN_MCP_TOKEN")
|
||||||
|
if not token:
|
||||||
|
sys.exit("BRAIN_MCP_TOKEN not set")
|
||||||
|
|
||||||
|
pairs = load_pairs(args.evalset)
|
||||||
|
if not pairs:
|
||||||
|
sys.exit(f"no pairs in {args.evalset}")
|
||||||
|
|
||||||
|
print(f"# {args.baseline} — {len(pairs)} questions, k={args.k}")
|
||||||
|
print()
|
||||||
|
hits1 = 0
|
||||||
|
hits3 = 0
|
||||||
|
detail = []
|
||||||
|
for q, expected in pairs:
|
||||||
|
try:
|
||||||
|
results = brain_query(token, q, k=args.k)
|
||||||
|
except Exception as e:
|
||||||
|
detail.append((q, expected, [], f"ERR {e}"))
|
||||||
|
continue
|
||||||
|
slugs = [slug_of(r) for r in results]
|
||||||
|
rank = slugs.index(expected) + 1 if expected in slugs else 0
|
||||||
|
h1 = 1 if rank == 1 else 0
|
||||||
|
h3 = 1 if 0 < rank <= 3 else 0
|
||||||
|
hits1 += h1
|
||||||
|
hits3 += h3
|
||||||
|
detail.append((q, expected, slugs, rank))
|
||||||
|
|
||||||
|
total = len(pairs)
|
||||||
|
print(f"top-1 hit rate: {hits1}/{total} = {100*hits1/total:.0f}%")
|
||||||
|
print(f"top-3 hit rate: {hits3}/{total} = {100*hits3/total:.0f}%")
|
||||||
|
print()
|
||||||
|
print("## per-question detail")
|
||||||
|
print()
|
||||||
|
for q, expected, slugs, rank in detail:
|
||||||
|
marker = {0: "✗", 1: "★", 2: "·", 3: "·"}.get(rank, "?")
|
||||||
|
if isinstance(rank, str):
|
||||||
|
marker = "!"
|
||||||
|
print(f"{marker} rank={rank} expected={expected}")
|
||||||
|
print(f" q: {q}")
|
||||||
|
for i, s in enumerate(slugs[:args.k], 1):
|
||||||
|
mark = " <-- expected" if s == expected else ""
|
||||||
|
print(f" {i}. {s}{mark}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{"_meta":true,"note":"Agent-consumer column of brain-MCP intent analysis. consumer_type fixed=autonomous_agent. CAVEAT: canonical schema file brain-intent-extraction.md is NOT present on this host (koala) — only this session's own task prompt references it. The closed intent vocabulary below was RECONSTRUCTED from the task prompt's framing + brain/schema.md. Re-map intent labels if the canonical vocab differs. schema_source=reconstructed on every row.","closed_intent_vocab":["semantic_retrieval","lexical_lookup","check_prior_art","synthesized_answer","store_new_knowledge","update_or_supersede","ingest_raw_source","verify_write_landed","discover_capability","intent_unclear"],"intent_tool_match_values":["match","mismatch","partial"],"corpus":"~/.claude/projects/*/*.jsonl (Claude Code agent transcripts on koala). brain/sessions/*.jsonl empty. agentsquad docs/eval/*.jsonl are code-review eval results, NOT brain calls. No separate Crush logs found. Zero brain calls appear under any mcp__ name with a human typing the call — all brain acts are agent-initiated (CLAUDE.md reflex), so all qualify as autonomous_agent."}
|
||||||
|
{"id":"a01","session":"tapir-c","ts":"2026-06-?T15:01:51","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"single pre-task query 'YouTube Data API captions download ownership limitation timedtext adapter Go' — named-entity lexical lookup, fit BM25 well, no reformulation."}
|
||||||
|
{"id":"a02","session":"tapir","ts":"2026-06-05T21:44:10","tool":"brain_ingest","intent":"ingest_raw_source","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_ingest 14s earlier — tool not ambient, had to be discovered/loaded first.","evidence":"source=tapir-scheduled-discovery-session-2026-06-05, a session learnings dump."}
|
||||||
|
{"id":"a03","session":"tapir","ts":"2026-06-?T14:00:59","tool":"brain_ingest","intent":"ingest_raw_source","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"source=tapir-rls-identity-bootstrapping, first write of RLS lesson."}
|
||||||
|
{"id":"a04","session":"tapir","ts":"2026-06-?T14:02:08","tool":"brain_ingest","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-INGESTED same source name 'tapir-rls-identity-bootstrapping' 69s later with edited/condensed body. No update/patch/supersede verb exists, so the agent overwrote-by-re-ingest. Whether this dedups or creates a v2 duplicate is opaque to the agent.","observed_friction":"agent revised content within 70s of first write — classic edit-after-write with no edit primitive.","evidence":"two brain_ingest, identical source string, divergent content."}
|
||||||
|
{"id":"a05","session":"tapir","ts":"2026-06-?T14:56:27","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"postgres-cascade-skips-tables-without-fk.md — distinct new lesson."}
|
||||||
|
{"id":"a06","session":"tapir","ts":"2026-06-?T21:08:57","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_query — discovery tax again.","evidence":"'Dex passwords.dex.coreos.com CRD ...' keyword-rich, single shot."}
|
||||||
|
{"id":"a07","session":"AI-infra","ts":"2026-05-?T15:38:03","tool":"brain_query(HTTP-curl)","intent":"discover_capability","intent_tool_match":"mismatch","workaround":"raw `curl -X POST` to brain-mcp endpoint instead of MCP tool. Preceded by two ToolSearch ('brain knowledge memory' then 'brain') that did not yield a usable loaded tool, so agent fell back to HTTP.","observed_friction":"3-step ladder: ToolSearch 'brain knowledge memory' -> ToolSearch 'brain' -> curl. Agent did not know which act maps to which tool name.","evidence":"curl -s -o /tmp/brain-init.txt -w code:%{http_code} -X POST ..."}
|
||||||
|
{"id":"a08","session":"AI-infra","ts":"2026-05-?T04:46:13","tool":"brain_query(HTTP-curl)","intent":"discover_capability","intent_tool_match":"mismatch","workaround":"hand-set TOKEN=... then curl brain-test endpoint — probing whether the HTTP brain path is reachable/authed at all. MCP path not used.","observed_friction":"agent testing connectivity by hand; MCP auth/availability not trusted.","evidence":"TOKEN=...; curl -s -o /tmp/brain-test ..."}
|
||||||
|
{"id":"a09","session":"AI-infra","ts":"2026-05-?T05:23:35","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_query (after an earlier 05:03 ToolSearch 'brain ingestion knowledge wiki' that explored layers).","evidence":"'koala machine state RTX 5070 llama-swap' — named-entity recall, fits lexical."}
|
||||||
|
{"id":"a10","session":"AI-infra","ts":"2026-05-?T05:27:36","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 3 in ~1s (k3s/flux gitops; llama-swap ai-stack GPU; MCP Dex OAuth claude.ai) — parallel prior-art sweep, all named-entity."}
|
||||||
|
{"id":"a11","session":"AI-infra","ts":"2026-05-?T07:13:50","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 4 in ~2s before a debugging session (flux healthCheck; exit 255 restart loop; NVML mismatch; mirror rebase). Named symptoms, lexical fit OK on first pass."}
|
||||||
|
{"id":"a12","session":"AI-infra","ts":"2026-05-?T07:14:26","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"4 writes in ~35s (flux-healthcheck-stale; exit-255-unknown-reason-not-oom; nvidia-nvml-mismatch; mcp-static-bearer) — answers to the 4 queries just run, captured as lessons. Healthy query->fix->write loop."}
|
||||||
|
{"id":"a13","session":"AI-infra","ts":"2026-05-?T07:15:25","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"25s after writing exit-255-unknown-reason-not-oom.md, re-queried 'exit 255 unknown SIGKILL containerd' — reformulated terms (SIGKILL/containerd not in original query 'exit 255 unknown reason restart loop diagnosis'). Either confirming the fresh write is retrievable or re-searching because first lexical query missed. No read-after-write / get-by-id act exists.","observed_friction":"reformulation chain: 'exit 255 unknown reason restart loop diagnosis' -> 'exit 255 unknown SIGKILL containerd'. Same need, different keywords.","evidence":"query at 07:13:51 vs 07:15:25 bracketing the 07:14:37 write."}
|
||||||
|
{"id":"a14","session":"AI-infra","ts":"2026-05-?T07:22:55","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 5 in ~20s before a homelab security audit (piguard/iguana tailscale; unifi UCG firewall; SOPS age; ingress TLS cert-manager; koala UFW iptables). Named-entity sweep."}
|
||||||
|
{"id":"a15","session":"AI-infra","ts":"2026-05-?T09:27:53","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"audit-shortcut-tls-blocks-zero; policy-audit-mode-blocks-nothing — distinct new audit lessons."}
|
||||||
|
{"id":"a16","session":"AI-infra","ts":"2026-05-?T09:28:22","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"homelab-security-chains-not-bugs.md FIRST write (worked example: koala 2026-05-13)."}
|
||||||
|
{"id":"a17","session":"AI-infra","ts":"2026-05-?T10:32:49","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE homelab-security-chains-not-bugs.md ~64min later with a different/expanded worked example (host-user dotfile, over-broad ClusterRole). Same filename, additive revision, no patch/append/supersede verb — agent overwrites and hopes the index replaces rather than duplicates.","observed_friction":"the in-between hour of audit work produced a better example; only way to fold it in was a full re-write of the same slug.","evidence":"two brain_write same filename at 09:28:22 and 10:32:49, divergent worked examples."}
|
||||||
|
{"id":"a18","session":"AI-infra","ts":"2026-05-?T10:18:25","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"homelab-document-accepted-risk-to-break-audit-cycle.md — distinct."}
|
||||||
|
{"id":"a19","session":"AI-infra","ts":"2026-05-?T10:32:55","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"6s after the homelab-chains re-write, queried 'RBAC MCP cluster pods log chain' — checking the chain reasoning is retrievable / finding the related entry. Read-after-write done via lexical search.","observed_friction":null,"evidence":"query immediately follows the 10:32:49 write."}
|
||||||
|
{"id":"a20","session":"AI-infra","ts":"2026-05-?T18:57:34","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_write,brain_query — re-discovered tools this session.","evidence":"'extension build pinned version major version upgrade postgres pgvector'."}
|
||||||
|
{"id":"a21","session":"AI-infra","ts":"2026-05-?T18:57:58","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"extension-version-lags-platform-major-upgrade.md."}
|
||||||
|
{"id":"a22","session":"AI-infra","ts":"2026-05-?T18:58:06","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"8s after writing extension-version-lags, re-queried 'pgvector postgres extension version compile error bump' — reformulated from the 18:57:34 query ('extension build pinned version...'). Lexical re-search to confirm the just-written lesson is findable, with different keyword guess.","observed_friction":"reformulation: 'extension build pinned version major version upgrade postgres pgvector' -> 'pgvector postgres extension version compile error bump'.","evidence":"write at 18:57:58 bracketed by queries 18:57:34 and 18:58:06."}
|
||||||
|
{"id":"a23","session":"AI-infra","ts":"2026-05-?T18:31:23","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"webfetch-readme-when-image-or-flag-uncertain.md FIRST write."}
|
||||||
|
{"id":"a24","session":"AI-infra","ts":"2026-05-?T18:34:23","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE webfetch-readme-when-image-or-flag-uncertain.md 3min later, near-identical body. Looks like a retry/overwrite (uncertain the first landed, or minor edit). No idempotent upsert with confirmation, so agent re-fires the write.","observed_friction":"followed 7s later by a brain_query on the same topic ('OSS tool image registry CLI flag webhook path schema drift README pre-flight') — write-write-query, i.e. overwrite then verify-by-search.","evidence":"two brain_write same filename 18:31:23 / 18:34:23, then query 18:34:31."}
|
||||||
|
{"id":"a25","session":"AI-infra","ts":"2026-05-?T18:34:31","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"keyword-stuffed lexical query 'OSS tool image registry CLI flag webhook path schema drift README pre-flight' fired right after the webfetch-readme write — agent dumps every concept token hoping BM25 surfaces its own fresh note. This is semantic intent (find that conceptual lesson) coerced into a bag-of-keywords.","observed_friction":"query is a concatenation of the note's section headings — a tell that the agent is groping lexically for content it knows by meaning.","evidence":"query text mirrors the just-written note's bullet topics."}
|
||||||
|
{"id":"a26","session":"dev","ts":"2026-06-?T21:18:26","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_answer.","evidence":"'tapir transcript persistence shared cross-user dedup table RLS isolation ADR-021 ...' -> 22min later a brain_write (acted on the answer). Answer consumed, not re-queried. Healthy."}
|
||||||
|
{"id":"a27","session":"dev","ts":"2026-06-?T21:40:20","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"tapir-migration-and-rls-test-infra-gotchas, with wing/hall absent here (flat) — see schema-confusion note a40."}
|
||||||
|
{"id":"a28","session":"dev","ts":"2026-06-?T05:35:04","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"partial","workaround":"asked 'gitea MCP not working workaround file issue via API which token ... how to authenticate gitea API' — a how-do-I question. Next brain act (05:39 query) is a different topic (tapir transcript), so the answer was apparently sufficient OR abandoned; ambiguous.","observed_friction":null,"evidence":"brain_answer then unrelated brain_query 4min later."}
|
||||||
|
{"id":"a29","session":"dev","ts":"2026-06-?T05:39:54","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'tapir transcript persistence ADR-021 shared non-RLS'."}
|
||||||
|
{"id":"a30","session":"dev","ts":"2026-06-?T05:57:37","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"gitea-mcp-per-repo-tools-404-and-rest-fallback FIRST write."}
|
||||||
|
{"id":"a31","session":"dev","ts":"2026-06-?T05:57:55","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE gitea-mcp-per-repo-tools-404-and-rest-fallback 18s later — overwrite/retry of same slug, no upsert confirmation.","observed_friction":"sub-20s gap = almost certainly a content tweak the agent could not express as an edit.","evidence":"two brain_write same filename 05:57:37 / 05:57:55."}
|
||||||
|
{"id":"a32","session":"dev","ts":"2026-05-?T11:51:47","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"infra-litellm-absorption-2026-05-16.md."}
|
||||||
|
{"id":"a33","session":"dev","ts":"2026-05-?T12:07:04","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"batch of 3 (litellm rebuild time piguard; docker compose orphaned volumes; prometheus_client ModuleNotFoundError) — lexical, error-string driven."}
|
||||||
|
{"id":"a34","session":"dev","ts":"2026-05-?T15:08:15","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"mismatch","workaround":"THREE brain_answer at 15:08 (moved compose volumes? / pi rebuild time? / litellm ModuleNotFound prometheus) — the SAME three topics queried lexically an hour earlier (12:07) — were IMMEDIATELY followed at 15:09 by THREE brain_query on the same three topics. The agent asked the synthesizer, was unsatisfied, and fell straight back to raw lexical search. Strongest answer->query fallback in the corpus.","observed_friction":"answer/query duplication across one intent: agent hedges by firing both interfaces, trusting neither.","evidence":"15:08 answers vs 15:09 queries, topic-for-topic aligned."}
|
||||||
|
{"id":"a35","session":"dev","ts":"2026-05-?T15:09:16","tool":"brain_query","intent":"semantic_retrieval","intent_tool_match":"mismatch","workaround":"after the 3 brain_answer calls failed to satisfy, re-issued as lexical brain_query ('moved compose stack to new directory volumes disappeared empty'; 'raspberry pi docker build time arm slow'; 'how to enable prometheus metrics on litellm proxy callback'). The want is meaning-based ('did my volumes move?') but the only retrieval that 'worked' was keyword search — and these are full natural-language sentences crammed into a BM25 box.","observed_friction":"natural-language questions ('how to enable...', 'moved ... disappeared') passed to a lexical query tool — semantic intent, lexical interface.","evidence":"3 queries at 15:09 mirror the 3 answers at 15:08."}
|
||||||
|
{"id":"a36","session":"dev","ts":"2026-05-?T20:31:50","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'What happened with the litellm migration on 2026-05-16?' — episodic recall question, answer fit; no re-query followed."}
|
||||||
|
{"id":"a37","session":"dev","ts":"2026-05-?T21:07:17","tool":"brain_query","intent":"semantic_retrieval","intent_tool_match":"mismatch","workaround":"FOUR-step reformulation chain over one Go bug: 'bytes.Buffer Bytes Reset aliasing slice sharing' -> 'go buffer reuse map backing array bug' -> [write go-bytes-buffer-bytes-reset-aliasing-trap.md] -> 'go map values all show same content after loop' -> 'bytes.Buffer Bytes returns same data every iteration'. The agent knows the SYMPTOM (all map values identical) and the CAUSE (Bytes() aliasing) but cannot phrase a single lexical query that bridges them — it wants concept retrieval and is forced to brute-force keyword variants.","observed_friction":"4 distinct phrasings of the same bug, two before and two after writing the lesson — also doubles as verify_write_landed on the trailing queries.","evidence":"21:07:17, 21:07:17, (write 21:08:01), 21:08:10, 21:08:19."}
|
||||||
|
{"id":"a38","session":"dev","ts":"2026-05-?T21:08:01","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"go-bytes-buffer-bytes-reset-aliasing-trap.md."}
|
||||||
|
{"id":"a39","session":"dev","ts":"2026-05-?T07:54:26","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"mcp-tool-design-get-needs-list-partner.md — a design principle."}
|
||||||
|
{"id":"a40","session":"dev","ts":"2026-06-?T06:25:00","tool":"brain_query","intent":"check_prior_art","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'Dex to Authentik migration auth.d-ma.be issuer cutover OIDC subject ...'."}
|
||||||
|
{"id":"a41","session":"dev","ts":"2026-06-?T06:25:08","tool":"brain_answer","intent":"synthesized_answer","intent_tool_match":"mismatch","workaround":"brain_query (a40) and brain_answer (a41) fired ~8s apart on the SAME intent (Dex->Authentik subject-keyed token orphan). Agent runs lexical search AND synthesized answer in parallel for one question rather than choosing — it cannot predict which interface will return usable knowledge, so it pays both.","observed_friction":"query+answer doublet on one need.","evidence":"06:25:00 query then 06:25:08 answer, same topic."}
|
||||||
|
{"id":"a42","session":"dev","ts":"2026-06-?T13:48:49","tool":"brain_query","intent":"verify_write_landed","intent_tool_match":"mismatch","workaround":"'authentik cutover validation probe' then 6min later 'authentik cutover post-flip validation' — reformulated pair, likely searching for the agent's own earlier cutover notes / confirming validation steps are recorded. Lexical re-search standing in for recall-my-recent-context.","observed_friction":"reformulation: 'validation probe' -> 'post-flip validation'.","evidence":"13:48:49 and 13:54:56."}
|
||||||
|
{"id":"a43","session":"dev","ts":"2026-06-?T13:59:17","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":"preceded by ToolSearch select:brain_write.","evidence":"oidc-issuer-host-change-vs-idp-swap-subject."}
|
||||||
|
{"id":"a44","session":"dev","ts":"2026-06-?T13:59:50","tool":"brain_write","intent":"store_new_knowledge","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"cannot-move-ingress-host-across-namespaces-flux-dryrun FIRST write."}
|
||||||
|
{"id":"a45","session":"dev","ts":"2026-06-?T14:00:13","tool":"brain_write","intent":"update_or_supersede","intent_tool_match":"mismatch","workaround":"RE-WROTE cannot-move-ingress-host-across-namespaces-flux-dryrun 23s later — overwrite of same slug, no edit/upsert primitive.","observed_friction":"sub-30s gap = content correction expressed as a full re-write.","evidence":"two brain_write same filename 13:59:50 / 14:00:13."}
|
||||||
|
{"id":"a46","session":"dev","ts":"2026-06-?T13:53:54","tool":"brain_write(HTTP-staged)","intent":"store_new_knowledge","intent_tool_match":"mismatch","workaround":"after `ToolSearch select:mcp__claude_ai_brain__authenticate` (MCP auth flow), the agent staged the entry as `cat > /tmp/brain_entry.json` ({filename:'postgres-force-rls-cross-u...', content}) for a curl write rather than calling brain_write directly — MCP write path was not usable (auth/loading), so it dropped to the HTTP bodge.","observed_friction":"reached for an 'authenticate' tool, then abandoned MCP for hand-built JSON + curl. Matches known pattern: brain/op MCP auth lapses often.","evidence":"ToolSearch authenticate 13:53:27 -> cat /tmp/brain_entry.json 13:53:54."}
|
||||||
|
{"id":"a47","session":"template-go-agent","ts":"2026-05-?T18:46:26","tool":"BASH(not-a-brain-act)","intent":"intent_unclear","intent_tool_match":"match","workaround":null,"observed_friction":null,"evidence":"'brain_' substring was inside a git commit message body ('agent boundaries, network policy, agent s...'), NOT a brain call. Excluded from knowledge-act analysis; logged for audit completeness."}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Agent-Consumer Brain Intent Analysis — koala column
|
||||||
|
|
||||||
|
**Consumer:** `autonomous_agent` (all rows). **Host:** koala. **Date:** 2026-06-15.
|
||||||
|
**Raw rows:** `agent-intent-column.jsonl` (46 real knowledge-acts + 1 excluded false-positive).
|
||||||
|
|
||||||
|
## Caveat — canonical schema not on this host
|
||||||
|
|
||||||
|
The shared closed-vocabulary file `brain-intent-extraction.md` **does not exist on
|
||||||
|
koala** — the only reference to it is inside *this task's own prompt*. The intent
|
||||||
|
vocabulary below was **reconstructed** from the prompt's framing + `brain/schema.md`.
|
||||||
|
Every row carries `schema_source: reconstructed`. If the canonical vocab differs,
|
||||||
|
re-map the `intent` field; the `intent_tool_match` / `workaround` / `observed_friction`
|
||||||
|
evidence stands regardless of label names.
|
||||||
|
|
||||||
|
**Reconstructed closed vocab:** `semantic_retrieval`, `lexical_lookup`,
|
||||||
|
`check_prior_art`, `synthesized_answer`, `store_new_knowledge`, `update_or_supersede`,
|
||||||
|
`ingest_raw_source`, `verify_write_landed`, `discover_capability`, `intent_unclear`.
|
||||||
|
|
||||||
|
## Corpus
|
||||||
|
|
||||||
|
- `~/.claude/projects/*/*.jsonl` — Claude Code agent transcripts (98 files). **The only
|
||||||
|
source with brain calls.**
|
||||||
|
- `brain/sessions/*.jsonl` — empty (only `.gitkeep`).
|
||||||
|
- `agentsquad docs/eval/*.jsonl` — code-review eval results, **not** brain calls.
|
||||||
|
- No separate Crush session logs on this host.
|
||||||
|
- **Zero** brain calls were human-typed. Every brain act is agent-initiated (the
|
||||||
|
CLAUDE.md "query as reflex / close-the-loop write" behaviour), so all qualify as
|
||||||
|
`autonomous_agent`. The human gave the top-level task; the agent chose every brain act.
|
||||||
|
|
||||||
|
## 1. Intent histogram, split by `intent_tool_match`
|
||||||
|
|
||||||
|
| intent | match | mismatch | partial | total |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| check_prior_art | 10 | 0 | 0 | 10 |
|
||||||
|
| store_new_knowledge | 14 | 1 | 0 | 15 |
|
||||||
|
| update_or_supersede | 0 | 5 | 0 | 5 |
|
||||||
|
| synthesized_answer | 2 | 2 | 1 | 5 |
|
||||||
|
| verify_write_landed | 0 | 4 | 0 | 4 |
|
||||||
|
| semantic_retrieval | 0 | 3 | 0 | 3 |
|
||||||
|
| ingest_raw_source | 2 | 0 | 0 | 2 |
|
||||||
|
| discover_capability | 0 | 2 | 0 | 2 |
|
||||||
|
| **total** | **28** | **17** | **1** | **46** |
|
||||||
|
|
||||||
|
> Batch note: several rows collapse a same-second fan-out of identical-intent calls
|
||||||
|
> (a10=3, a11=4, a14=5, a33=3, a34=3, a35=3). Call-level the corpus is ~62 brain calls;
|
||||||
|
> the table counts the 46 distinct knowledge-acts. Frequency is deliberately *not* the
|
||||||
|
> point — the mismatch column is.
|
||||||
|
|
||||||
|
**37% of agent knowledge-acts (17/46) are interface mismatches.** Every mismatch falls
|
||||||
|
into one of four intents: `update_or_supersede`, `verify_write_landed`,
|
||||||
|
`semantic_retrieval`, `discover_capability` — plus one `store` that had to use HTTP.
|
||||||
|
|
||||||
|
## 2. Mismatch list, grouped by intent (primary deliverable)
|
||||||
|
|
||||||
|
### update_or_supersede → re-write same slug (5/5 mismatch) — HIGHEST VALUE
|
||||||
|
There is **no update / patch / append / supersede verb**. When an agent improves a note
|
||||||
|
it already wrote, the only move is to call `brain_write`/`brain_ingest` **again with the
|
||||||
|
same filename/source** and hope the index replaces rather than duplicates. Observed:
|
||||||
|
|
||||||
|
| slug | 1st write | 2nd write | gap | what changed |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `tapir-rls-identity-bootstrapping` (ingest) | 14:00:59 | 14:02:08 | 69s | condensed body |
|
||||||
|
| `homelab-security-chains-not-bugs.md` | 09:28:22 | 10:32:49 | 64m | new worked example |
|
||||||
|
| `webfetch-readme-when-image-or-flag-uncertain.md` | 18:31:23 | 18:34:23 | 3m | near-identical (retry) |
|
||||||
|
| `gitea-mcp-per-repo-tools-404-and-rest-fallback` | 05:57:37 | 05:57:55 | 18s | content tweak |
|
||||||
|
| `cannot-move-ingress-host-across-namespaces-flux-dryrun` | 13:59:50 | 14:00:13 | 23s | content tweak |
|
||||||
|
|
||||||
|
Sub-30s gaps (3 of 5) read as "I wanted to edit but can only overwrite." The agent has
|
||||||
|
no way to know whether the second write deduped or created a contradictory v2 — opacity
|
||||||
|
the brain's own design principle (`mcp-tool-design-get-needs-list-partner.md`, written
|
||||||
|
*by one of these very agents*) would flag: every `_write` needs a `_get`/`_update` partner.
|
||||||
|
|
||||||
|
### verify_write_landed → lexical re-query (4/4 mismatch)
|
||||||
|
No read-after-write / get-by-id confirmation. After every substantive write, agents
|
||||||
|
re-query lexically to check the note is retrievable — and *reformulate the keywords*
|
||||||
|
because they can't predict what BM25 indexed:
|
||||||
|
- `exit-255` lesson: query `exit 255 unknown reason restart loop diagnosis` → write →
|
||||||
|
query `exit 255 unknown SIGKILL containerd`.
|
||||||
|
- `extension-version-lags`: query `extension build pinned version...pgvector` → write →
|
||||||
|
query `pgvector postgres extension version compile error bump`.
|
||||||
|
- `webfetch-readme`: write → write → query stuffed with the note's own section headings.
|
||||||
|
|
||||||
|
### semantic_retrieval → BM25 keyword-stuffing (3/3 mismatch)
|
||||||
|
Agent knows the *meaning* but not the *indexed words*, so it brute-forces phrasings of
|
||||||
|
one need against a lexical tool:
|
||||||
|
- **4-step chain on one Go bug:** `bytes.Buffer Bytes Reset aliasing slice sharing` →
|
||||||
|
`go buffer reuse map backing array bug` → (write) → `go map values all show same
|
||||||
|
content after loop` → `bytes.Buffer Bytes returns same data every iteration`. Symptom
|
||||||
|
and cause both known; no single lexical query bridges them.
|
||||||
|
- Natural-language questions (`how to enable prometheus metrics on litellm proxy
|
||||||
|
callback`, `moved compose stack to new directory volumes disappeared empty`) shoved
|
||||||
|
into `brain_query`.
|
||||||
|
|
||||||
|
### synthesized_answer → fall back to / hedge with brain_query (2 mismatch + 1 partial)
|
||||||
|
`brain_answer` is frequently **not trusted as terminal**:
|
||||||
|
- **Strongest signal:** 3× `brain_answer` at 15:08 (compose volumes / pi rebuild time /
|
||||||
|
litellm ModuleNotFound) → 3× `brain_query` at 15:09 on the *same three topics*. The
|
||||||
|
agent asked the synthesizer, was unsatisfied, and immediately re-ran raw search.
|
||||||
|
- Dex→Authentik: `brain_query` and `brain_answer` fired **8s apart on one question** —
|
||||||
|
the agent pays both interfaces because it can't predict which returns usable knowledge.
|
||||||
|
- (Counter-examples exist: `brain_answer` for episodic recall — "what happened with the
|
||||||
|
litellm migration on 2026-05-16?" — was consumed and not re-queried. So `answer`
|
||||||
|
works for *episodic/temporal* recall, fails for *how-do-I / does-X-hold* reasoning.)
|
||||||
|
|
||||||
|
### discover_capability + store-via-HTTP (3 mismatch)
|
||||||
|
brain tools are **not ambient** — they are deferred and must be `ToolSearch`-loaded each
|
||||||
|
session. Agents fumble the discovery (`ToolSearch 'brain knowledge memory'` →
|
||||||
|
`'brain'` → `'brain ingestion knowledge wiki'`) and, when MCP load/auth fails, drop to
|
||||||
|
**raw `curl` against `brain-mcp` / hand-built `/tmp/brain_entry.json`**. One agent even
|
||||||
|
`ToolSearch`-ed an `authenticate` tool, then abandoned MCP for the HTTP bodge — matching
|
||||||
|
the known "brain/op MCP auth lapses too often" footgun.
|
||||||
|
|
||||||
|
### Write-interface / layer schema confusion (cross-cutting)
|
||||||
|
`brain_write` was called with **three different param shapes** in the same corpus:
|
||||||
|
`{filename, type:"lesson", content}`, `{filename, content}` (no type), and
|
||||||
|
`{wing:"tapir", hall:"failures", filename, content}` — plus `brain_ingest {source,
|
||||||
|
content}`. Agents are unsure which verb and which layer (flat slug vs `wing`/`hall`
|
||||||
|
knowledge routing vs raw ingest) a given knowledge-act maps to. This is the
|
||||||
|
`knowledge/ vs wiki/` confusion expressed at the parameter level.
|
||||||
|
|
||||||
|
## 3. `intent_unclear` rate
|
||||||
|
|
||||||
|
**0 / 46 genuine brain acts (0%).** Agent intent is unusually legible because these are
|
||||||
|
Claude Code transcripts: the surrounding task, the query/filename strings, and the
|
||||||
|
write content all disambiguate. One row (`a47`) was tagged `intent_unclear` and
|
||||||
|
**excluded** — its `brain_` substring was inside a git commit message, not a brain call.
|
||||||
|
Example of the only ambiguity that arose: a `brain_answer` on "gitea MCP not working...
|
||||||
|
how to authenticate" followed by an unrelated query — can't tell if the answer satisfied
|
||||||
|
or was abandoned (`partial`, row a28).
|
||||||
|
|
||||||
|
## 4. The single biggest intent↔interface gap
|
||||||
|
|
||||||
|
**The brain offers one write verb and one lexical read verb, but autonomous agents
|
||||||
|
perform four distinct knowledge-acts against them — and three of the four have no fitting
|
||||||
|
interface.** The deepest gap is the **missing update/supersede path**: agents close every
|
||||||
|
task by writing a lesson (the CLAUDE.md ritual), routinely improve it minutes-to-an-hour
|
||||||
|
later, and — having no edit primitive — re-write the same slug blind, unable to tell
|
||||||
|
whether they corrected the entry or forked a contradiction into the index. This compounds
|
||||||
|
with the lexical-only read side: because there is no `get-by-id` or semantic retrieval,
|
||||||
|
agents can't even reliably *find their own just-written note* to check it, so they
|
||||||
|
keyword-stuff reformulated queries and hedge `brain_answer` with parallel `brain_query`.
|
||||||
|
The interface is built for *append-and-keyword-search*; the agents are trying to
|
||||||
|
*curate a living, deduplicated knowledge base*, and the seam between those two shows up
|
||||||
|
as the 5 blind re-writes, 4 read-after-write re-queries, and 3 semantic-as-lexical chains
|
||||||
|
that dominate the mismatch column.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Evidence-only per task scope — no redesign proposed.*
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Brain-MCP Intent↔Interface Findings — Unified (two-column merge)
|
||||||
|
|
||||||
|
**Status — 2026-06-16**
|
||||||
|
- ✅ **Agent column** filled from `agent-intent-column.jsonl` (46 acts, koala).
|
||||||
|
- ⏳ **Human column** = `PENDING`. Drop the Claude.ai-history analysis into
|
||||||
|
`human-intent-column.jsonl` (same dir, schema below), then fill the `PENDING`
|
||||||
|
cells and the synthesis blocks marked `<<SYNTH>>`.
|
||||||
|
- ⚠️ Canonical `brain-intent-extraction.md` still absent on koala. Vocab below is
|
||||||
|
the **reconstructed** lock both columns must share. If the real file surfaces,
|
||||||
|
re-map `intent` labels in *both* columns identically before merging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared schema (LOCKED — both columns conform)
|
||||||
|
|
||||||
|
Per-call row, JSONL:
|
||||||
|
|
||||||
|
| field | values / form | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `a01..` (agent) / `h01..` (human) | column prefix kept distinct |
|
||||||
|
| `session` | string | source session/conversation id |
|
||||||
|
| `ts` | ISO-8601 | best-effort |
|
||||||
|
| `tool` | brain tool name (+ `(HTTP-curl)` / `(HTTP-staged)` suffix for bodges) | |
|
||||||
|
| `intent` | closed vocab ↓ | the knowledge-act WANTED |
|
||||||
|
| `intent_tool_match` | `match` \| `mismatch` \| `partial` | does the called tool fit the want |
|
||||||
|
| `consumer_type` | `autonomous_agent` \| `human_interactive` | fixed per column |
|
||||||
|
| `workaround` | string \| null | the bodge when mismatch — **primary signal** |
|
||||||
|
| `observed_friction` | string \| null | reformulation chains, discovery tax, hedging |
|
||||||
|
| `evidence` | string | excerpt anchoring the classification |
|
||||||
|
| `schema_source` | `reconstructed` | flip to `canonical` if real vocab lands |
|
||||||
|
|
||||||
|
### Closed intent vocab (LOCKED)
|
||||||
|
`semantic_retrieval`, `lexical_lookup`, `check_prior_art`, `synthesized_answer`,
|
||||||
|
`store_new_knowledge`, `update_or_supersede`, `ingest_raw_source`,
|
||||||
|
`verify_write_landed`, `discover_capability`, `intent_unclear`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Master comparison — by intent
|
||||||
|
|
||||||
|
| intent | agent acts | agent mismatch | human acts | human mismatch | shared gap |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| check_prior_art | 10 | 0% | `PENDING` | `PENDING` | — |
|
||||||
|
| store_new_knowledge | 15 | 7% (1/15) | `PENDING` | `PENDING` | `<<SYNTH>>` |
|
||||||
|
| update_or_supersede | 5 | **100%** (5/5) | `PENDING` | `PENDING` | `<<SYNTH>>` no edit verb |
|
||||||
|
| synthesized_answer | 5 | 40% (2/5)+1 partial | `PENDING` | `PENDING` | `<<SYNTH>>` |
|
||||||
|
| verify_write_landed | 4 | **100%** (4/4) | `PENDING` | `PENDING` | `<<SYNTH>>` no read-after-write |
|
||||||
|
| semantic_retrieval | 3 | **100%** (3/3) | `PENDING` | `PENDING` | `<<SYNTH>>` lexical-only read |
|
||||||
|
| ingest_raw_source | 2 | 0% | `PENDING` | `PENDING` | — |
|
||||||
|
| discover_capability | 2 | **100%** (2/2) | `PENDING` | `PENDING` | agent-specific (ToolSearch/auth)? |
|
||||||
|
| intent_unclear | 0 | — | `PENDING` | `PENDING` | divergence expected ↓ |
|
||||||
|
| **TOTAL** | **46** | **37% (17)** | `PENDING` | `PENDING` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-intent merged findings
|
||||||
|
|
||||||
|
### update_or_supersede — agent: 5/5 mismatch (highest value)
|
||||||
|
**Agent:** no edit/patch/append verb. Agents re-write same slug blind:
|
||||||
|
`homelab-security-chains-not-bugs.md` (+64m), `tapir-rls-identity-bootstrapping`,
|
||||||
|
`webfetch-readme...`, `gitea-mcp-per-repo-tools-404...`,
|
||||||
|
`cannot-move-ingress-host...` — 3 of 5 sub-30s ("wanted edit, got overwrite").
|
||||||
|
Cannot tell if write deduped or forked a contradiction.
|
||||||
|
**Human:** `PENDING` — *look for: user editing a prior note, asking "update what I
|
||||||
|
saved about X", or expressing frustration that an old fact is stale/duplicated.*
|
||||||
|
**<<SYNTH>>** shared verdict once both filled.
|
||||||
|
|
||||||
|
### verify_write_landed — agent: 4/4 mismatch
|
||||||
|
**Agent:** no `get-by-id`/read-after-write. Agents lexically re-query their own
|
||||||
|
fresh note with reformulated keywords (`exit 255 unknown reason` → `...SIGKILL
|
||||||
|
containerd`; `extension build pinned...` → `pgvector ...compile error bump`).
|
||||||
|
**Human:** `PENDING` — *humans may not exhibit this (they trust the write UI
|
||||||
|
confirmation). If absent in human column, it's an agent-specific gap → flag.*
|
||||||
|
**<<SYNTH>>**.
|
||||||
|
|
||||||
|
### semantic_retrieval — agent: 3/3 mismatch
|
||||||
|
**Agent:** meaning known, indexed words unknown → BM25 keyword-stuffing. 4-step
|
||||||
|
chain on one Go `bytes.Buffer` bug; NL questions shoved into `brain_query`.
|
||||||
|
**Human:** `PENDING` — *humans likely hit this HARDER (they phrase conversationally).
|
||||||
|
Compare reformulation-chain length agent vs human.*
|
||||||
|
**<<SYNTH>>** — likely the strongest cross-consumer overlap.
|
||||||
|
|
||||||
|
### synthesized_answer — agent: 2 mismatch + 1 partial
|
||||||
|
**Agent:** `brain_answer` not trusted terminal — 3 answers → 3 same-topic queries
|
||||||
|
1min later; query+answer fired 8s apart hedging one need. Works for *episodic*
|
||||||
|
recall, fails for *how-do-I / does-X-hold*.
|
||||||
|
**Human:** `PENDING` — *humans may prefer `brain_answer` as primary (chat-native).
|
||||||
|
If human match-rate >> agent, the tool fits humans not agents → key divergence.*
|
||||||
|
**<<SYNTH>>**.
|
||||||
|
|
||||||
|
### store_new_knowledge — agent: 14/15 match
|
||||||
|
**Agent:** healthy, except 1 HTTP-staged bodge when MCP auth lapsed. Also surfaced
|
||||||
|
write-schema confusion: 3 param shapes (`{filename,type}` / `{filename}` /
|
||||||
|
`{wing,hall,filename}`) + `ingest{source}`.
|
||||||
|
**Human:** `PENDING` — *humans rarely write directly; expect low volume.*
|
||||||
|
**<<SYNTH>>**.
|
||||||
|
|
||||||
|
### check_prior_art / ingest_raw_source — agent: 0% mismatch
|
||||||
|
Lexical fits named-entity recall and raw-source capture. **Human:** `PENDING`.
|
||||||
|
|
||||||
|
### discover_capability — agent: 2/2 mismatch (agent-specific)
|
||||||
|
Brain tools deferred → `ToolSearch`-load each session; auth lapse → `curl` bodge.
|
||||||
|
**Likely has NO human analog** (humans get ambient connectors). Candidate for
|
||||||
|
"agent-only gap" bucket. **Human:** `PENDING` to confirm absent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-consumer divergence — questions to resolve at merge
|
||||||
|
|
||||||
|
1. **intent_unclear rate.** Agent = 0% (transcripts self-document). Human expected
|
||||||
|
higher (conversational, implicit). Big delta = the columns measure legibility
|
||||||
|
differently, not just intent.
|
||||||
|
2. **Where does each consumer's mismatch concentrate?** Agent mismatch is
|
||||||
|
write-side-heavy (supersede + verify-landed = 9/17). Hypothesis: human mismatch
|
||||||
|
is read-side-heavy (semantic + answer). If true → **the interface fails the two
|
||||||
|
consumers at opposite ends.**
|
||||||
|
3. **Agent-only gaps** (`discover_capability`, `verify_write_landed`) vs
|
||||||
|
**shared gaps** (`semantic_retrieval`, `update_or_supersede`). Shared gaps =
|
||||||
|
highest-priority evidence; agent-only = harness/auth issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Combined headline — `<<SYNTH>>` (fill when human column lands)
|
||||||
|
|
||||||
|
> Agent-side draft (to be reconciled with human-side):
|
||||||
|
> Brain = append + keyword-search; agents want a curated, dedup'd, self-verifying KB.
|
||||||
|
> Missing update/supersede path + lexical-only reads are the seam. **Open question
|
||||||
|
> for the merge: do humans hit the same read-side wall, making semantic-retrieval the
|
||||||
|
> universal gap — or do agents uniquely suffer the write-side (supersede / verify)
|
||||||
|
> wall that humans sidestep via the chat UI?**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Drop-in checklist (when human column arrives)
|
||||||
|
1. Place `human-intent-column.jsonl` in this dir; conform to LOCKED schema.
|
||||||
|
2. Fill every `PENDING` cell in master table + per-intent blocks.
|
||||||
|
3. Resolve the 3 divergence questions with evidence.
|
||||||
|
4. Replace each `<<SYNTH>>` with the reconciled verdict; write the combined headline.
|
||||||
|
5. If canonical vocab surfaced: re-map both columns' `intent`, flip `schema_source`.
|
||||||
|
6. Commit as `docs(brain): merge human+agent intent columns`.
|
||||||
@@ -112,16 +112,15 @@ Flags:
|
|||||||
- `--out PATH` — output file (default `./.mcp.json`)
|
- `--out PATH` — output file (default `./.mcp.json`)
|
||||||
- `--force` — overwrite an existing file
|
- `--force` — overwrite an existing file
|
||||||
|
|
||||||
Modes:
|
Modes (all list **brain + gitea** — Gitea is the audit-trail invariant of the
|
||||||
|
consolidated single harness, #75):
|
||||||
|
|
||||||
- **cloud** — brain MCP only. Claude Code with no routing.
|
- **cloud** — brain + gitea MCP.
|
||||||
- **client-local** — brain + routing pod. The `routing` entry points at
|
- **client-local** — brain + gitea MCP. (The former `routing` entry pointing at
|
||||||
`koala:30310/mcp` (the routing pod, deployed in Plan 6). The
|
`koala:30310/mcp` was removed — the routing pod is iceboxed, #75.)
|
||||||
`X-Hyperguild-Mode: client-local` header is forward-compat for future
|
- **sovereign** — brain + gitea, with a `_mode_note` explaining that this mode
|
||||||
modes; the pod treats absent or unknown values as `client-local`.
|
primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude Code fallback
|
||||||
- **sovereign** — brain only, with a `_mode_note` explaining that this
|
for emergency offline use.
|
||||||
mode primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude
|
|
||||||
Code fallback for emergency offline use.
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
|
|||||||
+26
-19
@@ -59,31 +59,40 @@ func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// giteaMCPURL is the public Gitea MCP endpoint (OAuth via Dex/Authentik). Gitea
|
||||||
|
// is the audit-trail invariant of the consolidated single harness (#75), so every
|
||||||
|
// mode lists it as an available connection.
|
||||||
|
const giteaMCPURL = "https://git-mcp.d-ma.be/mcp"
|
||||||
|
|
||||||
|
func brainEntry(brainURL string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"url": brainURL + "/mcp",
|
||||||
|
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func giteaEntry() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"url": giteaMCPURL,
|
||||||
|
"description": "Gitea MCP — issues/PRs/repo ops (audit-trail invariant)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func modeCloud(brainURL string) map[string]any {
|
func modeCloud(brainURL string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"mcpServers": map[string]any{
|
"mcpServers": map[string]any{
|
||||||
"brain": map[string]any{
|
"brain": brainEntry(brainURL),
|
||||||
"url": brainURL + "/mcp",
|
"gitea": giteaEntry(),
|
||||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func modeClientLocal(brainURL string) map[string]any {
|
func modeClientLocal(brainURL string) map[string]any {
|
||||||
|
// The routing pod is iceboxed (#75); the consolidated harness is brain + gitea.
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"mcpServers": map[string]any{
|
"mcpServers": map[string]any{
|
||||||
"brain": map[string]any{
|
"brain": brainEntry(brainURL),
|
||||||
"url": brainURL + "/mcp",
|
"gitea": giteaEntry(),
|
||||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
|
||||||
},
|
|
||||||
"routing": map[string]any{
|
|
||||||
"url": "http://koala:30310/mcp",
|
|
||||||
"description": "Mode 2 routing pod — routes skill calls to LiteLLM/local",
|
|
||||||
"headers": map[string]any{
|
|
||||||
"X-Hyperguild-Mode": "client-local",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,10 +101,8 @@ func modeSovereign(brainURL string) map[string]any {
|
|||||||
return map[string]any{
|
return map[string]any{
|
||||||
"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).",
|
"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).",
|
||||||
"mcpServers": map[string]any{
|
"mcpServers": map[string]any{
|
||||||
"brain": map[string]any{
|
"brain": brainEntry(brainURL),
|
||||||
"url": brainURL + "/mcp",
|
"gitea": giteaEntry(),
|
||||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-17
@@ -35,11 +35,13 @@ func TestRunMode_Cloud_Default(t *testing.T) {
|
|||||||
servers, ok := got["mcpServers"].(map[string]any)
|
servers, ok := got["mcpServers"].(map[string]any)
|
||||||
require.True(t, ok, "mcpServers must be a JSON object")
|
require.True(t, ok, "mcpServers must be a JSON object")
|
||||||
assert.Contains(t, servers, "brain")
|
assert.Contains(t, servers, "brain")
|
||||||
|
assert.Contains(t, servers, "gitea")
|
||||||
assert.NotContains(t, servers, "routing")
|
assert.NotContains(t, servers, "routing")
|
||||||
assert.NotContains(t, got, "_mode_note")
|
assert.NotContains(t, got, "_mode_note")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
func TestRunMode_ClientLocal_NoRouting_HasGitea(t *testing.T) {
|
||||||
|
// #75: the routing pod is iceboxed; the consolidated harness is brain + gitea.
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
outPath := filepath.Join(dir, ".mcp.json")
|
outPath := filepath.Join(dir, ".mcp.json")
|
||||||
t.Setenv("BRAIN_URL", "http://koala:30330")
|
t.Setenv("BRAIN_URL", "http://koala:30330")
|
||||||
@@ -51,17 +53,11 @@ func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
|||||||
got := readJSON(t, outPath)
|
got := readJSON(t, outPath)
|
||||||
servers := got["mcpServers"].(map[string]any)
|
servers := got["mcpServers"].(map[string]any)
|
||||||
require.Contains(t, servers, "brain")
|
require.Contains(t, servers, "brain")
|
||||||
require.Contains(t, servers, "routing")
|
require.Contains(t, servers, "gitea")
|
||||||
|
assert.NotContains(t, servers, "routing", "routing pod iceboxed (#75)")
|
||||||
routing := servers["routing"].(map[string]any)
|
|
||||||
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
|
|
||||||
|
|
||||||
headers, ok := routing["headers"].(map[string]any)
|
|
||||||
require.True(t, ok, "routing entry should have headers block")
|
|
||||||
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
func TestModeGiteaEntryPresentAndWellFormed(t *testing.T) {
|
||||||
tmp := t.TempDir() + "/mcp.json"
|
tmp := t.TempDir() + "/mcp.json"
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
stderr := &bytes.Buffer{}
|
stderr := &bytes.Buffer{}
|
||||||
@@ -73,13 +69,10 @@ func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(body, &doc))
|
require.NoError(t, json.Unmarshal(body, &doc))
|
||||||
|
|
||||||
servers := doc["mcpServers"].(map[string]any)
|
servers := doc["mcpServers"].(map[string]any)
|
||||||
routing := servers["routing"].(map[string]any)
|
require.NotContains(t, servers, "routing")
|
||||||
assert.Equal(t, "http://koala:30310/mcp", routing["url"])
|
gitea, ok := servers["gitea"].(map[string]any)
|
||||||
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
|
require.True(t, ok, "gitea entry must be present (audit-trail invariant)")
|
||||||
|
assert.Equal(t, "https://git-mcp.d-ma.be/mcp", gitea["url"])
|
||||||
headers, ok := routing["headers"].(map[string]any)
|
|
||||||
require.True(t, ok, "routing entry should have headers block")
|
|
||||||
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
||||||
@@ -94,6 +87,7 @@ func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
|||||||
assert.Contains(t, got, "_mode_note")
|
assert.Contains(t, got, "_mode_note")
|
||||||
servers := got["mcpServers"].(map[string]any)
|
servers := got["mcpServers"].(map[string]any)
|
||||||
assert.Contains(t, servers, "brain")
|
assert.Contains(t, servers, "brain")
|
||||||
|
assert.Contains(t, servers, "gitea")
|
||||||
assert.NotContains(t, servers, "routing")
|
assert.NotContains(t, servers, "routing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/tier"
|
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultAnthropicProbe = "https://api.anthropic.com"
|
const defaultAnthropicProbe = "https://api.anthropic.com"
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// The internal/skills/{debug,retrospective,review,trainer} packages imported
|
|
||||||
// below are also imported by cmd/supervisor. Plan 7 (supervisor retirement)
|
|
||||||
// MUST NOT delete these four packages — the routing pod is their second
|
|
||||||
// consumer. Plan 7 deletes only internal/skills/{tdd,spec,tier} (the skills
|
|
||||||
// that don't route to local), the supervisor binary, and supervisor manifests.
|
|
||||||
// See docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md (Constraints).
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/supervisor/internal/auth"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/config"
|
|
||||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/registry"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/routing"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/debug"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/project"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/review"
|
|
||||||
"github.com/mathiasbq/supervisor/internal/skills/trainer"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
|
||||||
slog.SetDefault(logger)
|
|
||||||
|
|
||||||
cfg, err := config.LoadRouting()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("config load failed", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
configDir := envOr("SUPERVISOR_CONFIG_DIR", "/app/config/supervisor")
|
|
||||||
mustRead := func(path string) string {
|
|
||||||
b, err := os.ReadFile(configDir + "/" + path)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("read prompt failed", "path", path, "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
llm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
|
|
||||||
|
|
||||||
router := &routing.Router{
|
|
||||||
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
|
|
||||||
Logger: routing.NewLogger(cfg.BrainURL),
|
|
||||||
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
|
|
||||||
FastModel: cfg.FastModel,
|
|
||||||
ThinkingModel: cfg.ThinkingModel,
|
|
||||||
Complete: llm.Complete,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skill packages call CompleteFunc(ctx, model, system, user) — no session_id
|
|
||||||
// or project_root in the signature. Rather than modifying every skill's API
|
|
||||||
// (and inflating Plan 6's blast radius), the routing pod logs every decision
|
|
||||||
// under a fixed session_id "_routing". Operators query
|
|
||||||
// `GET /pass-rate?skill=_routing&window=...` to inspect routing health.
|
|
||||||
const routingSessionID = "_routing"
|
|
||||||
wrap := func(skillName string) routing.CompleteFunc {
|
|
||||||
return func(ctx context.Context, _, system, user string) (string, int64, error) {
|
|
||||||
// The model param is ignored: the router picks the model based on policy.
|
|
||||||
return router.Run(ctx, routing.RunInput{
|
|
||||||
Skill: skillName,
|
|
||||||
System: system,
|
|
||||||
User: user,
|
|
||||||
SessionID: routingSessionID,
|
|
||||||
ProjectRoot: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := registry.New()
|
|
||||||
reg.Register(review.New(review.Config{
|
|
||||||
SkillPrompt: mustRead("review.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: review.CompleteFunc(wrap("review")),
|
|
||||||
}))
|
|
||||||
reg.Register(debug.New(debug.Config{
|
|
||||||
SkillPrompt: mustRead("debug.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: debug.CompleteFunc(wrap("debug")),
|
|
||||||
}))
|
|
||||||
reg.Register(retrospective.New(retrospective.Config{
|
|
||||||
SkillPrompt: mustRead("retrospective.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: retrospective.CompleteFunc(wrap("retrospective")),
|
|
||||||
}))
|
|
||||||
reg.Register(trainer.New(trainer.Config{
|
|
||||||
ReaderPrompt: mustRead("trainer-reader.md"),
|
|
||||||
WriterPrompt: mustRead("trainer-writer.md"),
|
|
||||||
DefaultModel: cfg.FastModel,
|
|
||||||
CompleteFunc: trainer.CompleteFunc(wrap("trainer")),
|
|
||||||
}))
|
|
||||||
|
|
||||||
if cfg.GiteaMCPURL != "" {
|
|
||||||
mcpC, err := mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("mcpclient init for project_create — GITEA_MCP_URL is set but GITEA_MCP_TOKEN is empty (check routing-secrets)", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
var ghClient *githubclient.Client
|
|
||||||
if cfg.GitHubPAT != "" {
|
|
||||||
ghClient = githubclient.New(cfg.GitHubPAT)
|
|
||||||
}
|
|
||||||
reg.Register(project.New(project.Config{
|
|
||||||
Client: mcpC,
|
|
||||||
GitHub: ghClient,
|
|
||||||
GiteaOwner: cfg.GiteaOwner,
|
|
||||||
GitHubOwner: cfg.GitHubOwner,
|
|
||||||
GitHubPAT: cfg.GitHubPAT,
|
|
||||||
InfraRepo: cfg.InfraRepo,
|
|
||||||
}))
|
|
||||||
logger.Info("project_create registered", "gitea_mcp_url", cfg.GiteaMCPURL,
|
|
||||||
"gitea_owner", cfg.GiteaOwner, "github_owner", cfg.GitHubOwner,
|
|
||||||
"infra_repo", cfg.InfraRepo, "github_pat_set", cfg.GitHubPAT != "")
|
|
||||||
} else {
|
|
||||||
logger.Info("project_create skipped — GITEA_MCP_URL not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
var validator *auth.Validator
|
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
||||||
audience := os.Getenv("MCP_AUDIENCE")
|
|
||||||
v, err := auth.NewValidator(dexURL, audience)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("build jwt validator", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
validator = v
|
|
||||||
logger.Info("jwt auth enabled", "issuer", dexURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := mcp.NewServer(reg, cfg.MCPAuthToken, validator)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.Handle("/mcp", srv)
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
|
||||||
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
|
||||||
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := ":" + cfg.Port
|
|
||||||
logger.Info("routing pod starting", "addr", addr,
|
|
||||||
"fast", cfg.FastModel, "thinking", cfg.ThinkingModel,
|
|
||||||
"floor", cfg.RouteLocalFloor, "ceil", cfg.RouteLocalCeil)
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec
|
|
||||||
logger.Error("server stopped", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOr(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package main_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestRoutingPodEndToEnd boots the binary against fake LiteLLM + brain servers,
|
|
||||||
// calls tools/list and one tools/call, and verifies the brain saw a session_log POST.
|
|
||||||
func TestRoutingPodEndToEnd(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("end-to-end binary boot")
|
|
||||||
}
|
|
||||||
|
|
||||||
var brainHits int
|
|
||||||
llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "stub"}}},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer llm.Close()
|
|
||||||
|
|
||||||
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/pass-rate":
|
|
||||||
brainHits++
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.95})
|
|
||||||
case "/mcp":
|
|
||||||
brainHits++
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer brain.Close()
|
|
||||||
|
|
||||||
port := freePort(t)
|
|
||||||
addr := "127.0.0.1:" + port
|
|
||||||
baseURL := "http://" + addr
|
|
||||||
|
|
||||||
bin := buildRouting(t)
|
|
||||||
cmd := exec.Command(bin)
|
|
||||||
cmd.Env = []string{
|
|
||||||
"ROUTING_PORT=" + port,
|
|
||||||
"LITELLM_BASE_URL=" + llm.URL,
|
|
||||||
"LITELLM_API_KEY=stub",
|
|
||||||
"BRAIN_URL=" + brain.URL,
|
|
||||||
"SUPERVISOR_CONFIG_DIR=../../config/supervisor",
|
|
||||||
"PATH=" + os.Getenv("PATH"),
|
|
||||||
"HOME=" + os.Getenv("HOME"),
|
|
||||||
}
|
|
||||||
require.NoError(t, cmd.Start())
|
|
||||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
|
||||||
|
|
||||||
require.NoError(t, waitForPort(t, addr, 30*time.Second))
|
|
||||||
|
|
||||||
resp := mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
|
||||||
assert.Contains(t, resp, `"review"`)
|
|
||||||
assert.Contains(t, resp, `"debug"`)
|
|
||||||
assert.Contains(t, resp, `"retrospective"`)
|
|
||||||
assert.Contains(t, resp, `"trainer"`)
|
|
||||||
|
|
||||||
resp = mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"review","arguments":{"project_root":"/tmp","files":["README.md"]}}}`)
|
|
||||||
_ = resp // shape varies by skill; we only need a 200
|
|
||||||
|
|
||||||
// Wait briefly for the async session_log to land.
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) && brainHits < 2 {
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
assert.GreaterOrEqual(t, brainHits, 2, "expected at least one /pass-rate hit and one /mcp session_log hit")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildRouting(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
bin := t.TempDir() + "/routing"
|
|
||||||
out, err := exec.Command("go", "build", "-o", bin, "github.com/mathiasbq/supervisor/cmd/routing").CombinedOutput()
|
|
||||||
require.NoError(t, err, "build failed: %s", out)
|
|
||||||
return bin
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForPort(_ *testing.T, addr string, dur time.Duration) error {
|
|
||||||
deadline := time.Now().Add(dur)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
c, err := http.Get("http://" + addr + "/healthz") //nolint:noctx
|
|
||||||
if err == nil {
|
|
||||||
_ = c.Body.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
conn, err := http.NewRequest(http.MethodPost, "http://"+addr+"/mcp", strings.NewReader(`{}`))
|
|
||||||
if err == nil {
|
|
||||||
r, err := http.DefaultClient.Do(conn)
|
|
||||||
if err == nil {
|
|
||||||
_ = r.Body.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return context.DeadlineExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
func mcpCall(t *testing.T, url, body string) string {
|
|
||||||
t.Helper()
|
|
||||||
r, err := http.Post(url, "application/json", strings.NewReader(body)) //nolint:noctx
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func() { _ = r.Body.Close() }()
|
|
||||||
raw, err := io.ReadAll(r.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// freePort grabs an OS-assigned TCP port and releases it. There is a small
|
|
||||||
// race window before the subprocess re-binds it, but it is acceptable for
|
|
||||||
// test isolation against a hardcoded port colliding with another test or
|
|
||||||
// stray process.
|
|
||||||
func freePort(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
port := l.Addr().(*net.TCPAddr).Port
|
|
||||||
require.NoError(t, l.Close())
|
|
||||||
return strconv.Itoa(port)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Runbook: exercising review/debug traffic to fill the pass-rate dataset
|
||||||
|
|
||||||
|
**Why this exists:** the routing pod's local-vs-cloud decision is gated on a
|
||||||
|
pass-rate history that only accrues from real `review`/`debug` invocations
|
||||||
|
**through the pod**. Until the dataset has data, the fast (local) path never
|
||||||
|
activates and the core hypothesis (hyperguild #35) can't be validated. This
|
||||||
|
runbook is how you spin that flywheel.
|
||||||
|
|
||||||
|
## The one trap
|
||||||
|
|
||||||
|
Pass-rate accrues **only** when a skill tool is called via the routing pod's MCP
|
||||||
|
endpoint. These look like they should count but **do not**:
|
||||||
|
|
||||||
|
- **Crush** — talks to LiteLLM directly, bypasses the pod. No log.
|
||||||
|
- **claude.ai web / Claude Desktop without the connector** — no log.
|
||||||
|
- **Running the local `code-review` / `debug` skills** (`~/dev/.skills`) inline in
|
||||||
|
a Claude Code session — those are local skills, not the pod's MCP tools. No log.
|
||||||
|
|
||||||
|
Only a `tools/call` to the routing pod records a pass/fail.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Purpose | URL | Auth |
|
||||||
|
|---------|-----|------|
|
||||||
|
| Routing MCP (local, Tailscale) | `http://koala:30310/mcp` | Bearer `ROUTING_MCP_TOKEN` |
|
||||||
|
| Routing MCP (remote) | `https://routing-mcp.d-ma.be/mcp` | OAuth via `auth.d-ma.be` (audience `claude-ai`) |
|
||||||
|
| Pass-rate readout | `http://koala:30330/pass-rate?skill=<name>` | none (read-only) |
|
||||||
|
|
||||||
|
Tools advertised: **`review`**, **`debug`** (the two the #35 gate measures),
|
||||||
|
plus `session_log`, `retrospective`, `trainer`.
|
||||||
|
|
||||||
|
## Step 1 — connect the routing pod as an MCP server
|
||||||
|
|
||||||
|
**Local** (needs the bearer token; keep it out of argv via 1Password):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
op run --env-file ~/.op-env -- \
|
||||||
|
claude mcp add routing --transport http http://koala:30310/mcp \
|
||||||
|
--header "Authorization: Bearer $ROUTING_MCP_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remote** (claude.ai / Claude Desktop): add a custom connector pointing at
|
||||||
|
`https://routing-mcp.d-ma.be/mcp`; it completes OAuth against `auth.d-ma.be`,
|
||||||
|
no static token.
|
||||||
|
|
||||||
|
Verify: a `tools/list` should return `review`, `debug`, `session_log`,
|
||||||
|
`retrospective`, `trainer`.
|
||||||
|
|
||||||
|
## Step 2 — route real work through it
|
||||||
|
|
||||||
|
In normal sessions, invoke the pod's tools instead of reviewing/debugging inline:
|
||||||
|
|
||||||
|
- *"Use the **routing** `review` tool on this diff."*
|
||||||
|
- *"**debug** this failure through the routing pod."*
|
||||||
|
|
||||||
|
Each call logs an outcome to ingestion → `/pass-rate` ticks up.
|
||||||
|
|
||||||
|
## Step 3 — how routing actually picks the model
|
||||||
|
|
||||||
|
Per `internal/routing/policy.go`:
|
||||||
|
|
||||||
|
1. pass-rate `nil` (cold) → **local** fast tier. The router defaults to local
|
||||||
|
from invocation #1, not to cloud — so the fast tier is exercised immediately.
|
||||||
|
2. pass-rate `>= 0.90` (floor) → **local**; `< 0.70` (ceil) → **cloud/thinking**;
|
||||||
|
in the `[0.70, 0.90)` band a request-hash bit samples 50/50.
|
||||||
|
3. On a local execution error the router falls open to the thinking model for
|
||||||
|
that one call (logged `thinking_fallback`).
|
||||||
|
|
||||||
|
So you are not "paying in on cloud" — cold calls already run on the (validated)
|
||||||
|
local fast tier **`koala/qwen36-35b-a3b`** (Qwen3.6-35B-A3B MTP, promoted
|
||||||
|
2026-06-29, infra `c66a195`, `HYPERGUILD_FAST_MODEL`). Accumulating passes just
|
||||||
|
keeps it there once real pass-rate is computed.
|
||||||
|
|
||||||
|
> **Instrumentation note (#73, fixed 2026-06-30):** until v0.11.1 the pod logged
|
||||||
|
> successes as `"skip"` (not `"pass"`), under `skill:"_routing"`, via an
|
||||||
|
> unauthenticated POST that silently 401'd — so `/pass-rate` stayed at zero no
|
||||||
|
> matter how much you used it. That's fixed and verified (a real review call now
|
||||||
|
> moves `/pass-rate?skill=review` 0→1). If you see traffic not registering,
|
||||||
|
> re-check #73's three failure modes first.
|
||||||
|
|
||||||
|
## Target & verification
|
||||||
|
|
||||||
|
- **50 logged invocations** across `review` + `debug` within the 14-day window.
|
||||||
|
The clock restarts **2026-06-30** (the day instrumentation was verified working;
|
||||||
|
the original 2026-06-26→07-10 window measured broken plumbing) → **kill-date
|
||||||
|
2026-07-14**, ~4 calls/day (1 already logged from the #73 smoke test).
|
||||||
|
- Check progress anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://koala:30330/pass-rate?skill=review"
|
||||||
|
curl -s "http://koala:30330/pass-rate?skill=debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
- If ~4–5/day isn't realistic alongside Crush, that is **not** a failure — per
|
||||||
|
#35 deliverable #1 it's the signal hyperguild isn't on the work critical path,
|
||||||
|
and the pre-decided **Berget fallback** (`gpt-oss-120b` / `qwen3-32b`) carries
|
||||||
|
the fast tier instead.
|
||||||
|
|
||||||
|
## Refs
|
||||||
|
|
||||||
|
- hyperguild #35 — the validation issue (data gate = deliverable #1)
|
||||||
|
- `docs/multi-model-routing.md` — routing policy
|
||||||
|
- brain: `wiki/homelab/hypotheses/qwen36-35b-a3b-fast-model-experiment-2026-05-28.md`
|
||||||
|
- infra `c66a195` — qwen36 promotion; `models.yml` / `llama-swap-configmap.yaml`
|
||||||
@@ -1051,7 +1051,7 @@ git push
|
|||||||
```bash
|
```bash
|
||||||
# Poll until complete (replace RUN_ID with the new run):
|
# Poll until complete (replace RUN_ID with the new run):
|
||||||
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
||||||
-H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \
|
-H "Authorization: token <REDACTED-dead-gitea-pat-infra#344>" \
|
||||||
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/mathiasbq/supervisor
|
module git.d-ma.be/mathias/hyperguild
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ FROM golang:1.26-bookworm AS builder
|
|||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Fetch internal gitea-hosted Go modules (mcp-chassis) without going through
|
||||||
|
# proxy.golang.org and without HTTP→HTTPS surprises. The Gitea server returns
|
||||||
|
# http:// in its go-import meta tag (config-level limitation), so rewrite to
|
||||||
|
# https here and bypass the module proxy + sumdb.
|
||||||
|
RUN git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
|
||||||
|
ENV GOPRIVATE=gitea.d-ma.be
|
||||||
|
ENV GOPROXY=direct
|
||||||
|
ENV GOSUMDB=off
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Command frontmatterlint reports markdown notes whose YAML frontmatter does
|
||||||
|
// not parse, and optionally repairs the ones that only need quoting.
|
||||||
|
//
|
||||||
|
// go run ./cmd/frontmatterlint ../../brain # report
|
||||||
|
// go run ./cmd/frontmatterlint -fix ../../brain # repair in place
|
||||||
|
//
|
||||||
|
// Exit codes: 0 clean (or everything repaired), 1 findings remain, 2 the scan
|
||||||
|
// itself failed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fix := flag.Bool("fix", false, "repair files that quoting can rescue, in place")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, err := frontmatter.Scan(root, *fix)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "frontmatterlint:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolved int
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.Repaired {
|
||||||
|
fmt.Printf("repaired %s\n", f.Path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unresolved++
|
||||||
|
fmt.Printf("BROKEN %s: %v\n", f.Path, f.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unresolved > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%d file(s) with unparseable frontmatter", unresolved)
|
||||||
|
if !*fix {
|
||||||
|
fmt.Fprint(os.Stderr, " — re-run with -fix")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ frontmatter: %d file(s) repaired, none broken\n", len(findings))
|
||||||
|
}
|
||||||
+353
-16
@@ -8,23 +8,98 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// kubeClient builds an in-cluster Kubernetes client (falls back to
|
||||||
|
// $KUBECONFIG for local dev/testing against a real cluster).
|
||||||
|
func kubeClient() (kubernetes.Interface, error) {
|
||||||
|
if cfg, err := rest.InClusterConfig(); err == nil {
|
||||||
|
return kubernetes.NewForConfig(cfg)
|
||||||
|
}
|
||||||
|
rules := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||||
|
cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{})
|
||||||
|
cfg, err := cc.ClientConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kube config (no in-cluster, no kubeconfig): %w", err)
|
||||||
|
}
|
||||||
|
return kubernetes.NewForConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claudeSink converts each claudewatcher.Batch into a raw session dump
|
||||||
|
// under brain/archive/claude-sessions/<host>/. Deliberately NOT a wiki
|
||||||
|
// note (api.WriteNote / brain/wiki/) — raw full transcripts out-ranked
|
||||||
|
// curated ai-sessions summaries in BM25 (177,818 vs 45,335 on the same
|
||||||
|
// query) and duplicated content already summarized elsewhere. Kept for
|
||||||
|
// deep lookups, never indexed. See ai-sessions#10.
|
||||||
|
type claudeSink struct {
|
||||||
|
brainDir string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
|
||||||
|
if len(b.Turns) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "# Claude session %s (%s)\n\n", b.SessionID, b.Host)
|
||||||
|
fmt.Fprintf(&sb, "_Project: `%s`. File: `%s`. Turns: %d._\n\n", b.ProjectID, b.FilePath, len(b.Turns))
|
||||||
|
for _, t := range b.Turns {
|
||||||
|
fmt.Fprintf(&sb, "## %s — %s\n\n", t.Type, t.Timestamp.UTC().Format(time.RFC3339))
|
||||||
|
if t.ToolName != "" {
|
||||||
|
fmt.Fprintf(&sb, "_tool: `%s`_\n\n", t.ToolName)
|
||||||
|
}
|
||||||
|
// Cap per-turn excerpt to keep page size bounded; the full
|
||||||
|
// transcript lives on disk under ~/.claude/projects/ already.
|
||||||
|
content := t.Content
|
||||||
|
if len(content) > 2000 {
|
||||||
|
content = content[:2000] + "…"
|
||||||
|
}
|
||||||
|
sb.WriteString(content)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
slug := "session-" + b.Host + "-" + b.SessionID
|
||||||
|
dest := filepath.Join(s.brainDir, "archive", "claude-sessions", b.Host, slug+".md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create claude-sessions archive dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, []byte(sb.String()), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write claude session archive: %w", err)
|
||||||
|
}
|
||||||
|
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// redactDSN parses a Postgres URL and replaces its password with `***`
|
// redactDSN parses a Postgres URL and replaces its password with `***`
|
||||||
// for safe inclusion in logs. Falls back to a non-leaking placeholder
|
// for safe inclusion in logs. Falls back to a non-leaking placeholder
|
||||||
// if parsing fails — we never log a raw DSN.
|
// if parsing fails — we never log a raw DSN.
|
||||||
@@ -69,6 +144,62 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildNotifier returns the ntfy notifier, or a nil interface when
|
||||||
|
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||||
|
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
||||||
|
func buildNotifier() audit.Notifier {
|
||||||
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
||||||
|
// set it builds the classification-aware DegradingSink (loki central +
|
||||||
|
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
||||||
|
// otherwise it falls back to a plain slog sink. The buffer lives under the
|
||||||
|
// brain dir so it survives process restarts.
|
||||||
|
func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) capture.AuditSink {
|
||||||
|
lokiURL := os.Getenv("BRAIN_LOKI_URL")
|
||||||
|
central := audit.NewLokiCentral(lokiURL)
|
||||||
|
if central == nil {
|
||||||
|
logger.Info("capture audit: slog sink (BRAIN_LOKI_URL unset)")
|
||||||
|
return audit.NewSlogSink(logger)
|
||||||
|
}
|
||||||
|
buffer, err := audit.NewFileBuffer(filepath.Join(brainDir, ".audit-buffer", "capture.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("capture audit buffer init", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
notifier := buildNotifier()
|
||||||
|
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||||
|
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||||
|
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||||
|
return audit.NewDegradingSink(central, buffer, notifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitList parses a comma-separated env value into a trimmed,
|
||||||
|
// empty-free slice. Used for the capture sovereign-principal allowlist.
|
||||||
|
func splitList(v string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(v, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemHostname returns os.Hostname() with a "unknown" fallback so the
|
||||||
|
// caller never has to handle the rare error path.
|
||||||
|
func systemHostname() string {
|
||||||
|
h, err := os.Hostname()
|
||||||
|
if err != nil || h == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
@@ -105,6 +236,11 @@ func main() {
|
|||||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||||
}
|
}
|
||||||
|
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
|
||||||
|
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
|
||||||
|
tertiaryKey := os.Getenv("GEMINI_API_KEY")
|
||||||
|
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
|
||||||
|
}
|
||||||
answerComplete = router.Complete
|
answerComplete = router.Complete
|
||||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||||
}
|
}
|
||||||
@@ -116,6 +252,15 @@ func main() {
|
|||||||
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gitea ticket tracker for the capture capability (#52). Token via env
|
||||||
|
// only — never logged or in argv. Both vars must be set to enable it;
|
||||||
|
// gitea.New returns nil otherwise, leaving ticket integration off.
|
||||||
|
giteaURL := envOr("BRAIN_GITEA_URL", "https://git.d-ma.be")
|
||||||
|
if tracker := gitea.New(giteaURL, os.Getenv("BRAIN_GITEA_TOKEN")); tracker != nil {
|
||||||
|
mcpSrv = mcpSrv.WithIssueTracker(tracker)
|
||||||
|
logger.Info("brain gitea tracker configured", "url", giteaURL)
|
||||||
|
}
|
||||||
|
|
||||||
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
|
||||||
// be set together for the path to wire on; otherwise BM25-only.
|
// be set together for the path to wire on; otherwise BM25-only.
|
||||||
var vectorStore *vectorstore.PGStore
|
var vectorStore *vectorstore.PGStore
|
||||||
@@ -140,6 +285,32 @@ func main() {
|
|||||||
logger.Info("brain hybrid retrieval enabled",
|
logger.Info("brain hybrid retrieval enabled",
|
||||||
"pg", redactDSN(pgDSN),
|
"pg", redactDSN(pgDSN),
|
||||||
"embed_url", embedURL, "embed_model", embedModel)
|
"embed_url", embedURL, "embed_model", embedModel)
|
||||||
|
|
||||||
|
// Graph store shares the same postgres18 DSN as the vector
|
||||||
|
// store and is opt-in via BRAIN_GRAPH_ENABLED=true. Defaults
|
||||||
|
// to off so first rollout doesn't surprise — flip on after
|
||||||
|
// the migration completes and the backfill finishes.
|
||||||
|
if envOr("BRAIN_GRAPH_ENABLED", "false") == "true" {
|
||||||
|
gstore, gerr := graphstore.New(context.Background(), pgDSN)
|
||||||
|
if gerr != nil {
|
||||||
|
logger.Error("graph store init", "err", gerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if gerr := gstore.Init(context.Background()); gerr != nil {
|
||||||
|
logger.Error("graph store migrate", "err", gerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
mcpSrv = mcpSrv.WithGraph(gstore)
|
||||||
|
if envOr("BRAIN_GRAPH_BACKFILL", "false") == "true" {
|
||||||
|
n, berr := graphsync.BackfillFromBrainDir(context.Background(), gstore, brainDir)
|
||||||
|
if berr != nil {
|
||||||
|
logger.Warn("graph backfill incomplete", "indexed", n, "err", berr)
|
||||||
|
} else {
|
||||||
|
logger.Info("graph backfill complete", "indexed", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Info("brain graph enabled", "pg", redactDSN(pgDSN))
|
||||||
|
}
|
||||||
case pgDSN == "" && embedURL == "":
|
case pgDSN == "" && embedURL == "":
|
||||||
// disabled — fine
|
// disabled — fine
|
||||||
default:
|
default:
|
||||||
@@ -154,6 +325,36 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Built before the watcher, the graph backfill and session ingestion, and
|
||||||
|
// retried rather than fatal (#83). Two separate reasons:
|
||||||
|
//
|
||||||
|
// - On a cold cluster boot this pod can beat dex to serving and gets a
|
||||||
|
// 502 from ingress. Nothing is misconfigured; it simply started first.
|
||||||
|
// Exiting there meant CrashLoopBackOff redoing the backfill on every
|
||||||
|
// attempt, with exponential backoff stretching the brain outage.
|
||||||
|
// - Constructing it first means a genuine config error (wrong issuer,
|
||||||
|
// bad audience) fails in seconds instead of after all that work.
|
||||||
|
//
|
||||||
|
// The budget stays bounded: retrying forever would turn a misconfiguration
|
||||||
|
// into a process that looks alive and never serves.
|
||||||
|
jwtValidator, err := startupretry.Do(ctx,
|
||||||
|
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second},
|
||||||
|
func(ctx context.Context) (*chassisauth.JWTValidator, error) {
|
||||||
|
v, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("oidc discovery not ready, retrying", "err", err)
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("build jwt validator", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if jwtValidator != nil {
|
||||||
|
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
watcher.Start(ctx, watcher.Config{
|
watcher.Start(ctx, watcher.Config{
|
||||||
BrainDir: brainDir,
|
BrainDir: brainDir,
|
||||||
@@ -161,6 +362,103 @@ func main() {
|
|||||||
Pipeline: pipelineCfg,
|
Pipeline: pipelineCfg,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code session ingestion (hyperguild#27 / infra#73 Track E.1).
|
||||||
|
// Off by default — explicitly opt in by setting CLAUDE_SESSIONS_DIR
|
||||||
|
// to the ~/.claude/projects path. Requires BRAIN_PG_DSN for the
|
||||||
|
// cursor table (resumable offsets across restarts).
|
||||||
|
if claudeDir := os.Getenv("CLAUDE_SESSIONS_DIR"); claudeDir != "" {
|
||||||
|
if pgDSN == "" {
|
||||||
|
logger.Error("CLAUDE_SESSIONS_DIR set but BRAIN_PG_DSN missing — claudewatcher needs the cursor table")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Client-name guard. The env value is a regex alternation
|
||||||
|
// (e.g. "SEB|Mastercard"); we wrap it with word boundaries
|
||||||
|
// and case-insensitive flag so substrings inside longer
|
||||||
|
// identifiers don't false-match. Sourced from a SOPS secret
|
||||||
|
// so client identities never live in source.
|
||||||
|
if clientBlock := os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK"); clientBlock != "" {
|
||||||
|
pattern := `(?i)\b(` + clientBlock + `)\b`
|
||||||
|
if err := claudewatcher.RegisterRule("client-name", pattern); err != nil {
|
||||||
|
logger.Error("claudewatcher client-block rule invalid", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger.Info("claudewatcher client-block guard registered")
|
||||||
|
}
|
||||||
|
cursorStore, cerr := claudewatcher.NewCursorStore(ctx, pgDSN)
|
||||||
|
if cerr != nil {
|
||||||
|
logger.Error("claudewatcher cursor init", "err", cerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if cerr := cursorStore.Init(ctx); cerr != nil {
|
||||||
|
logger.Error("claudewatcher cursor migrate", "err", cerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
||||||
|
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
||||||
|
|
||||||
|
// Project directories excluded from ingestion entirely, as globs
|
||||||
|
// against the project directory name — e.g.
|
||||||
|
// "*cobalt-dingo*,*claude-worktrees-bridge-cse*".
|
||||||
|
//
|
||||||
|
// This is a veto list, deliberately named as one. It complements the
|
||||||
|
// client-name scrubber rather than replacing it: the scrubber drops
|
||||||
|
// individual TURNS whose content matches, while this never opens a
|
||||||
|
// project's files at all. Sessions working on live company financials
|
||||||
|
// are excluded here because their content is confidential in ways no
|
||||||
|
// pattern reliably recognises, and their learnings are captured
|
||||||
|
// deliberately through the session-close ritual instead.
|
||||||
|
//
|
||||||
|
// Caveat worth knowing: a project directory is derived from the
|
||||||
|
// session's WORKING DIRECTORY, not from the repositories it touches.
|
||||||
|
// A session run from ~/dev that happens to read a skipped repo will
|
||||||
|
// NOT be excluded by this. The scrubber remains the control for
|
||||||
|
// content; this is a control for whole workspaces.
|
||||||
|
skipProjects := splitList(os.Getenv("CLAUDE_INGEST_SKIP_PROJECTS"))
|
||||||
|
if len(skipProjects) > 0 {
|
||||||
|
logger.Info("claudewatcher: projects excluded from ingestion", "patterns", skipProjects)
|
||||||
|
}
|
||||||
|
|
||||||
|
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
||||||
|
go func() {
|
||||||
|
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
||||||
|
SessionsDir: claudeDir,
|
||||||
|
Host: host,
|
||||||
|
Interval: interval,
|
||||||
|
Sink: sink,
|
||||||
|
Cursors: cursorStore,
|
||||||
|
Logger: logger,
|
||||||
|
SkipProjects: skipProjects,
|
||||||
|
}); err != nil && err != context.Canceled {
|
||||||
|
logger.Error("claudewatcher exited", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
logger.Info("claudewatcher started",
|
||||||
|
"sessions_dir", claudeDir, "host", host, "interval", interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea push webhook -> on-demand brain-sync Job, instead of waiting up
|
||||||
|
// to 15 minutes for the next CronJob poll. Off by default (opt in via
|
||||||
|
// GITEA_WEBHOOK_SECRET) since it needs Job-create RBAC in the "brain"
|
||||||
|
// namespace that a fresh deploy won't have granted yet.
|
||||||
|
var webhookHandler *webhook.Handler
|
||||||
|
if webhookSecret := os.Getenv("GITEA_WEBHOOK_SECRET"); webhookSecret != "" {
|
||||||
|
kc, err := kubeClient()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("brain-sync webhook: kube client", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
webhookHandler = &webhook.Handler{
|
||||||
|
Secret: webhookSecret,
|
||||||
|
Clientset: kc,
|
||||||
|
Namespace: envOr("BRAIN_SYNC_NAMESPACE", "brain"),
|
||||||
|
CronJobName: envOr("BRAIN_SYNC_CRONJOB", "brain-sync"),
|
||||||
|
WatchRepo: envOr("BRAIN_SYNC_WATCH_REPO", "mathias/brain"),
|
||||||
|
Logger: logger,
|
||||||
|
}
|
||||||
|
logger.Info("brain-sync webhook enabled", "namespace", webhookHandler.Namespace, "cronjob", webhookHandler.CronJobName)
|
||||||
|
}
|
||||||
|
|
||||||
if vectorStore != nil {
|
if vectorStore != nil {
|
||||||
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
||||||
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
||||||
@@ -178,20 +476,13 @@ func main() {
|
|||||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||||
|
mux.HandleFunc("GET /pending", h.Pending)
|
||||||
|
mux.HandleFunc("POST /promote", h.Promote)
|
||||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||||
var jwtValidator *auth.Validator
|
if webhookHandler != nil {
|
||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||||
audience := os.Getenv("MCP_AUDIENCE")
|
|
||||||
v, err := auth.NewValidator(dexURL, audience)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("build jwt validator", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
jwtValidator = v
|
|
||||||
logger.Info("jwt auth enabled", "issuer", dexURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||||
// configured. Static-Bearer-only deployments leave this empty so
|
// configured. Static-Bearer-only deployments leave this empty so
|
||||||
// clients never see an OAuth challenge.
|
// clients never see an OAuth challenge.
|
||||||
@@ -199,13 +490,50 @@ func main() {
|
|||||||
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
|
||||||
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
resourceURL := os.Getenv("MCP_RESOURCE_URL")
|
||||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||||
auth.ProtectedResourceHandler(resourceURL, dexURL))
|
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
|
||||||
if resourceURL != "" {
|
if resourceURL != "" {
|
||||||
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.Handle("/mcp", mcp.BearerAuth(mcpToken, jwtValidator, resourceMetadataURL, mcpSrv))
|
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
|
||||||
|
|
||||||
|
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
|
||||||
|
// tracker to file action items, so it only mounts when Gitea is
|
||||||
|
// configured. It reuses the MCP server's graph-wired brain store (one
|
||||||
|
// implementation), the classification tags for the I1 gate, and a
|
||||||
|
// classification-aware audit sink (loki + durable buffer + ntfy when
|
||||||
|
// BRAIN_LOKI_URL is set, else a plain slog sink). The handler does its
|
||||||
|
// own auth (static + JWT) because it needs the principal to derive the
|
||||||
|
// trust-zone origin — the chassis middleware hides it.
|
||||||
|
if tracker := mcpSrv.IssueTracker(); tracker != nil {
|
||||||
|
classCfg, cerr := classification.Load(brainDir)
|
||||||
|
if cerr != nil {
|
||||||
|
logger.Error("load classification config", "err", cerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
auditSink := buildAuditSink(ctx, brainDir, logger)
|
||||||
|
captureSvc := capture.NewService(
|
||||||
|
mcpSrv.BrainStore(), tracker, classCfg, auditSink)
|
||||||
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
|
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||||
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
|
mux.Handle("POST /capture", captureH)
|
||||||
|
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
|
||||||
|
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
|
||||||
|
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||||
|
// WithCapture mutates the same instance, so the tool appears live.
|
||||||
|
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
|
// hyperguild#97: a governance-file rewrite gets the same trail a
|
||||||
|
// capture already gets, plus an out-of-band alert. Wired here because
|
||||||
|
// brain_edit_root needs the capture plumbing anyway — that is what
|
||||||
|
// re-derives the static principal its gate checks.
|
||||||
|
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
|
||||||
|
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
|
||||||
|
"governance_observer", true)
|
||||||
|
} else {
|
||||||
|
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||||
|
}
|
||||||
|
|
||||||
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
|
||||||
// integration UI, which has no static-Bearer field. Setting both
|
// integration UI, which has no static-Bearer field. Setting both
|
||||||
@@ -235,6 +563,15 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /metrics — unauthenticated Prometheus endpoint. kube-prometheus-stack
|
||||||
|
// scrapes it via the ServiceMonitor in k3s/apps/supervisor/. The metrics
|
||||||
|
// middleware below wraps every other registered handler so it observes
|
||||||
|
// real request latency. /metrics itself is excluded from its own
|
||||||
|
// observation by registering it on the outer mux (post-wrap).
|
||||||
|
reg := metrics.New()
|
||||||
|
mux.HandleFunc("GET /metrics", reg.Handler())
|
||||||
|
logger.Info("metrics endpoint registered", "path", "/metrics")
|
||||||
|
|
||||||
addr := ":" + port
|
addr := ":" + port
|
||||||
watchIntervalLog := "disabled"
|
watchIntervalLog := "disabled"
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
@@ -249,7 +586,7 @@ func main() {
|
|||||||
"watch_interval", watchIntervalLog,
|
"watch_interval", watchIntervalLog,
|
||||||
"mcp_enabled", true,
|
"mcp_enabled", true,
|
||||||
)
|
)
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := http.ListenAndServe(addr, reg.Middleware(mux)); err != nil {
|
||||||
logger.Error("server stopped", "err", err)
|
logger.Error("server stopped", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// ingestion/cmd/server/main_test.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sink := &claudeSink{brainDir: dir, logger: slog.New(slog.NewTextHandler(os.Stderr, nil))}
|
||||||
|
|
||||||
|
err := sink.Ingest(context.Background(), claudewatcher.Batch{
|
||||||
|
Host: "koala",
|
||||||
|
FilePath: "/host-home-claude/projects/-home-mathias-dev/abc.jsonl",
|
||||||
|
SessionID: "abc",
|
||||||
|
ProjectID: "-home-mathias-dev",
|
||||||
|
Turns: []claudewatcher.Turn{
|
||||||
|
{Type: "assistant", Content: "did a thing"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "archive", "claude-sessions", "koala", "session-koala-abc.md"))
|
||||||
|
require.NoError(t, err, "raw session dump must land in the non-indexed archive")
|
||||||
|
assert.Contains(t, string(got), "did a thing")
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
|
||||||
|
}
|
||||||
+49
-5
@@ -3,28 +3,72 @@ module github.com/mathiasbq/hyperguild/ingestion
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
k8s.io/api v0.31.3
|
||||||
|
k8s.io/apimachinery v0.31.3
|
||||||
|
k8s.io/client-go v0.31.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.22.4 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/imdario/mergo v0.3.6 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
golang.org/x/net v0.26.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.21.0 // indirect
|
||||||
|
golang.org/x/term v0.28.0 // indirect
|
||||||
|
golang.org/x/time v0.3.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
|
||||||
|
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.2.0
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
golang.org/x/crypto v0.32.0 // indirect
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
+143
-3
@@ -1,10 +1,47 @@
|
|||||||
|
git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
|
||||||
|
git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
|
||||||
|
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
|
||||||
|
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM=
|
||||||
|
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
||||||
|
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -13,6 +50,19 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
@@ -25,28 +75,118 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
|
|||||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
|
||||||
|
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||||
|
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||||
|
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||||
|
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||||
|
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||||
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
k8s.io/api v0.31.3 h1:umzm5o8lFbdN/hIXbrK9oRpOproJO62CV1zqxXrLgk8=
|
||||||
|
k8s.io/api v0.31.3/go.mod h1:UJrkIp9pnMOI9K2nlL6vwpxRzzEX5sWgn8kGQe92kCE=
|
||||||
|
k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4=
|
||||||
|
k8s.io/apimachinery v0.31.3/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
|
||||||
|
k8s.io/client-go v0.31.3 h1:CAlZuM+PH2cm+86LOBemaJI/lQ5linJ6UFxKX/SoG+4=
|
||||||
|
k8s.io/client-go v0.31.3/go.mod h1:2CgjPUTpv3fE5dNygAr2NcM8nhHzXvxB8KL5gYc3kJs=
|
||||||
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
|
||||||
|
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
|
||||||
|
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
||||||
|
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
||||||
|
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||||
|
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -57,6 +58,7 @@ type writeRequest struct {
|
|||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
Wing string `json:"wing,omitempty"`
|
Wing string `json:"wing,omitempty"`
|
||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
|
SourceType string `json:"source_type,omitempty"` // "external" opts a hall=facts entry out of the internal default
|
||||||
}
|
}
|
||||||
|
|
||||||
type ingestRequest struct {
|
type ingestRequest struct {
|
||||||
@@ -121,6 +123,7 @@ type WriteNoteOptions struct {
|
|||||||
Domain string
|
Domain string
|
||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
|
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteNote writes a markdown note into the brain. Returns the path
|
// WriteNote writes a markdown note into the brain. Returns the path
|
||||||
@@ -130,6 +133,16 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("content is required")
|
return "", fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In the primitive, not in a caller: POST /write reaches here directly,
|
||||||
|
// without the BrainStore, and a gate one door wide is not a gate.
|
||||||
|
// BrainStore still calls the strip itself to report Ref.Unlinked; the
|
||||||
|
// second pass is a no-op on already-clean content.
|
||||||
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
opts.Content = clean
|
||||||
|
|
||||||
if opts.Wing != "" && opts.Hall != "" {
|
if opts.Wing != "" && opts.Hall != "" {
|
||||||
return writeHallNote(brainDir, opts)
|
return writeHallNote(brainDir, opts)
|
||||||
}
|
}
|
||||||
@@ -150,30 +163,143 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
existingFields, body := splitFrontmatter(opts.Content)
|
||||||
|
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||||
|
for _, f := range existingFields {
|
||||||
|
existingByKey[f.key] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the claim-property envelope BEFORE creating the directory, so a
|
||||||
|
// rejected write leaves nothing behind — not even an empty hall dir
|
||||||
|
// (brain#21). Fail closed: a value outside the enum is worse than an absent
|
||||||
|
// one, because a lint skips what it cannot parse and the note then reads as
|
||||||
|
// classified while nothing acts on it.
|
||||||
|
if err := claim.ValidateEnvelope(scalarValues(existingFields)); err != nil {
|
||||||
|
return "", fmt.Errorf("claim envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
return "", fmt.Errorf("create hall dir: %w", err)
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
}
|
}
|
||||||
|
emitted := make(map[string]bool, 6)
|
||||||
|
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
||||||
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||||
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||||
if opts.Type != "" {
|
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
|
||||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
|
||||||
|
// writeField merges one key: opts.Content's own value (if the note already
|
||||||
|
// carries this field in its own frontmatter) always wins over the fallback,
|
||||||
|
// so promotion/extraction-step metadata survives verbatim instead of being
|
||||||
|
// shadowed by a second, stacked frontmatter block (#86).
|
||||||
|
writeField := func(key, fallback string) {
|
||||||
|
emitted[key] = true
|
||||||
|
if f, ok := existingByKey[key]; ok {
|
||||||
|
for _, line := range f.lines {
|
||||||
|
fm.WriteString(line)
|
||||||
|
fm.WriteString("\n")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fallback != "" {
|
||||||
|
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeField("type", opts.Type)
|
||||||
|
writeField("domain", opts.Domain)
|
||||||
|
|
||||||
|
sourceType := opts.SourceType
|
||||||
|
if sourceType == "" && opts.Hall == "facts" {
|
||||||
|
// Most hall=facts entries are first-party (an eval/benchmark the
|
||||||
|
// writer ran itself), not external claims — default to internal and
|
||||||
|
// require an explicit source_type: external opt-out for the rare
|
||||||
|
// citation-needing entry (brain-gardener#7).
|
||||||
|
sourceType = "internal"
|
||||||
|
}
|
||||||
|
writeField("source_type", sourceType)
|
||||||
|
|
||||||
|
for _, f := range existingFields {
|
||||||
|
if emitted[f.key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range f.lines {
|
||||||
|
fm.WriteString(line)
|
||||||
|
fm.WriteString("\n")
|
||||||
}
|
}
|
||||||
if opts.Domain != "" {
|
|
||||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
|
||||||
}
|
}
|
||||||
fm.WriteString("---\n")
|
fm.WriteString("---\n")
|
||||||
|
|
||||||
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
|
||||||
return "", fmt.Errorf("write: %w", err)
|
return "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
rel, _ := filepath.Rel(brainDir, dest)
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
return filepath.ToSlash(rel), nil
|
return filepath.ToSlash(rel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scalarValues flattens frontmatter fields to key -> scalar value for
|
||||||
|
// validation. Multi-line values (lists, nested blocks) yield the text after
|
||||||
|
// the colon on the first line, which is empty for a list — correct here,
|
||||||
|
// since every claim-property field is a scalar enum.
|
||||||
|
func scalarValues(fields []frontmatterField) map[string]string {
|
||||||
|
out := make(map[string]string, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if len(f.lines) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, value, found := strings.Cut(f.lines[0], ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[f.key] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||||
|
// along with its raw line and any indented continuation lines (e.g. a
|
||||||
|
// bulleted list value spanning multiple lines).
|
||||||
|
type frontmatterField struct {
|
||||||
|
key string
|
||||||
|
lines []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFrontmatter splits a leading "---\n...\n---\n" YAML block out of
|
||||||
|
// content, returning its top-level fields in original order and the
|
||||||
|
// remaining body. If content has no leading frontmatter block, fields is
|
||||||
|
// nil and body is content unchanged.
|
||||||
|
func splitFrontmatter(content string) (fields []frontmatterField, body string) {
|
||||||
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
|
return nil, content
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
i := 1
|
||||||
|
var cur *frontmatterField
|
||||||
|
for ; i < len(lines); i++ {
|
||||||
|
line := lines[i]
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||||
|
if cur != nil {
|
||||||
|
fields = append(fields, *cur)
|
||||||
|
}
|
||||||
|
key, _, _ := strings.Cut(line, ":")
|
||||||
|
cur = &frontmatterField{key: strings.TrimSpace(key), lines: []string{line}}
|
||||||
|
} else if cur != nil {
|
||||||
|
cur.lines = append(cur.lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur != nil {
|
||||||
|
fields = append(fields, *cur)
|
||||||
|
}
|
||||||
|
body = strings.Join(lines[i:], "\n")
|
||||||
|
return fields, body
|
||||||
|
}
|
||||||
|
|
||||||
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
||||||
// callers that have not adopted the wing/hall taxonomy.
|
// callers that have not adopted the wing/hall taxonomy.
|
||||||
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||||
@@ -332,11 +458,19 @@ func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportedExtensions lists file extensions that IngestPath will process.
|
// isSupportedExtension reports whether IngestPath will process ext.
|
||||||
var supportedExtensions = map[string]bool{
|
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg require docmark (ADR-0013) and are only
|
||||||
".md": true,
|
// supported when DOCMARK_URL is configured — checked per-call (not cached at
|
||||||
".txt": true,
|
// package init) so it reflects the environment at request time.
|
||||||
".pdf": true,
|
func isSupportedExtension(ext string) bool {
|
||||||
|
switch ext {
|
||||||
|
case ".md", ".txt", ".pdf":
|
||||||
|
return true
|
||||||
|
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
|
return os.Getenv("DOCMARK_URL") != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
// IngestPath handles POST /ingest-path — ingest a file or directory.
|
||||||
@@ -369,7 +503,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if !supportedExtensions[ext] {
|
if !isSupportedExtension(ext) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content, readErr := extract.Text(path)
|
content, readErr := extract.Text(path)
|
||||||
@@ -397,7 +531,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ext := strings.ToLower(filepath.Ext(req.Path))
|
ext := strings.ToLower(filepath.Ext(req.Path))
|
||||||
if !supportedExtensions[ext] {
|
if !isSupportedExtension(ext) {
|
||||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -483,6 +617,40 @@ func (h *Handler) BackfillRefs(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]int{"updated": n})
|
writeJSON(w, map[string]int{"updated": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pending handles GET /pending — list raw/ notes awaiting promotion.
|
||||||
|
func (h *Handler) Pending(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
pending, err := ListPending(h.brainDir)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("pending failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "pending error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"pending": pending})
|
||||||
|
}
|
||||||
|
|
||||||
|
type promoteRequest struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
Slug string `json:"slug,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||||
|
// hall / collision / missing source is a 400 (caller error), not a 500.
|
||||||
|
func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req promoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel, err := PromoteNote(h.brainDir, PromoteOptions(req))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"path": rel})
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||||
|
|||||||
@@ -118,6 +118,116 @@ func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
|||||||
assert.Contains(t, string(content), "Some learning.")
|
assert.Contains(t, string(content), "Some learning.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteIncludesSourceTypeWhenSet(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "# Claude session abc (koala)\n\nBody.\n",
|
||||||
|
Filename: "session-koala-abc",
|
||||||
|
Wing: "claude-sessions",
|
||||||
|
Hall: "facts",
|
||||||
|
Type: "source",
|
||||||
|
SourceType: "internal",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "source_type: internal")
|
||||||
|
assert.Contains(t, string(got), "wing: claude-sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallFactsDefaultsSourceTypeInternalWhenUnset(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "manually captured fact.\n",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Most hall=facts entries are first-party (an eval/benchmark the agent ran
|
||||||
|
// itself), not external claims — default to internal, require explicit
|
||||||
|
// opt-out for the rare case that does need a citation (brain-gardener#7).
|
||||||
|
assert.Contains(t, string(got), "source_type: internal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallFactsPreservesExplicitExternalSourceType(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "vendor pricing claim, needs a citation.\n",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "facts",
|
||||||
|
SourceType: "external",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "source_type: external")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteOmitsSourceTypeForNonFactsHalls(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "a decision record.\n",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(got), "source_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\ntitle: act_runner host-executor\ntags: [gitea-actions, act_runner]\n---\n\n# Body\n\nSome content.\n",
|
||||||
|
Filename: "act-runner-host-executor",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "failures",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
body := string(got)
|
||||||
|
|
||||||
|
// exactly one frontmatter block: only two "---" delimiter lines total
|
||||||
|
assert.Equal(t, 2, strings.Count(body, "---\n"), "expected a single merged frontmatter block, not stacked blocks")
|
||||||
|
assert.Contains(t, body, "wing: homelab")
|
||||||
|
assert.Contains(t, body, "hall: failures")
|
||||||
|
assert.Contains(t, body, "title: act_runner host-executor")
|
||||||
|
assert.Contains(t, body, "tags: [gitea-actions, act_runner]")
|
||||||
|
assert.Contains(t, body, "# Body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\ntype: hypothesis\n---\n\nBody.\n",
|
||||||
|
Filename: "note",
|
||||||
|
Wing: "agentsquad",
|
||||||
|
Hall: "decisions",
|
||||||
|
Type: "decision", // should lose to content's own "type: hypothesis"
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "type: hypothesis")
|
||||||
|
assert.NotContains(t, string(got), "type: decision")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||||
dir, h := setup(t)
|
dir, h := setup(t)
|
||||||
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||||
@@ -330,3 +440,102 @@ func TestIngestPath_Directory(t *testing.T) {
|
|||||||
require.True(t, ok, "pages must be an array")
|
require.True(t, ok, "pages must be an array")
|
||||||
assert.NotEmpty(t, pagesSlice)
|
assert.NotEmpty(t, pagesSlice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- brain#21: claim-property envelope on the write path -------------------
|
||||||
|
|
||||||
|
func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) {
|
||||||
|
// writeHallNote already passes unknown keys through; this pins that
|
||||||
|
// behaviour for the envelope specifically, so a future refactor of the
|
||||||
|
// merge loop cannot silently drop it.
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "half_life: durable")
|
||||||
|
assert.Contains(t, string(got), "applies: universal")
|
||||||
|
assert.Contains(t, string(got), "claim_provenance: asserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) {
|
||||||
|
// Fail closed: a value outside the enum must not land. A lint skips what
|
||||||
|
// it cannot parse, so an unvalidated write produces a note that reads as
|
||||||
|
// classified while nothing acts on it.
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n",
|
||||||
|
Filename: "bad-envelope",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "half_life")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\napplies: homelab\n---\n\nBody.\n",
|
||||||
|
Filename: "no-provenance",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim_provenance")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HTTP POST /write door reaches WriteNote directly, without the
|
||||||
|
// BrainStore, so the unresolved-wikilink strip has to live in the primitive
|
||||||
|
// or that door stays ungated.
|
||||||
|
func TestWriteNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "See [[target]] and [[gitea-ci]].\n",
|
||||||
|
Filename: "src", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "[[target]]")
|
||||||
|
assert.Contains(t, string(got), "and gitea-ci.")
|
||||||
|
assert.NotContains(t, string(got), "[[gitea-ci]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rel, _, _, err := api.UpdateNote(dir, api.UpdateNoteOptions{
|
||||||
|
Wing: "a", Hall: "facts", Slug: "n",
|
||||||
|
Content: "Related: [[secret-hygiene]].\n", Reason: "r",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Related: secret-hygiene.")
|
||||||
|
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// ingestion/internal/api/ingestpath_docmark_test.go
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIngestPath_DocxUnsupportedWhenDocmarkNotConfigured(t *testing.T) {
|
||||||
|
t.Setenv("DOCMARK_URL", "")
|
||||||
|
_, h := setup(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestPath_DocxSupportedWhenDocmarkConfigured(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"# Converted Doc"}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
_, h := setup(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(f, []byte("fake docx"), 0o644))
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"path": f, "source": "test-doc", "dry_run": true})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.IngestPath(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||||
|
pages, ok := resp["pages"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.NotEmpty(t, pages)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PendingNote describes a raw/ note awaiting human promotion to the wiki.
|
||||||
|
type PendingNote struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
|
||||||
|
// deriving the default promoted slug.
|
||||||
|
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
||||||
|
|
||||||
|
// ListPending returns the notes in brain/raw/ awaiting review, oldest-first
|
||||||
|
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
||||||
|
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
||||||
|
func ListPending(brainDir string) ([]PendingNote, error) {
|
||||||
|
dir := filepath.Join(brainDir, "raw")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []PendingNote{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]PendingNote, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, statErr := e.Info()
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if readErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
created := fm.get("created_at")
|
||||||
|
if created == "" {
|
||||||
|
created = info.ModTime().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(),
|
||||||
|
CreatedAt: created,
|
||||||
|
SizeBytes: info.Size(),
|
||||||
|
Excerpt: excerpt(body, 200),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteOptions identifies a raw note to promote and its wiki destination.
|
||||||
|
type PromoteOptions struct {
|
||||||
|
Filename string // basename in brain/raw/
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
|
||||||
|
// rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and
|
||||||
|
// any custom fields), writes to brain/wiki/<wing>/<hall>/<slug>.md, deletes
|
||||||
|
// the source, then rebuilds the wing index and runs auto-tunnel detection.
|
||||||
|
//
|
||||||
|
// It is atomic from the caller's view: validation (hall, wing, slug,
|
||||||
|
// collision) happens before any filesystem change, and the source is deleted
|
||||||
|
// only after the destination write succeeds (write-then-delete, never move).
|
||||||
|
// Returns the promoted note's path relative to brainDir.
|
||||||
|
func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||||
|
// Validate filename (basename only — no traversal) before touching fs.
|
||||||
|
base := filepath.Base(opts.Filename)
|
||||||
|
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
||||||
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := opts.Slug
|
||||||
|
if slug == "" {
|
||||||
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||||
|
}
|
||||||
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||||
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(brainDir, "raw", base)
|
||||||
|
raw, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("read source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision: never silently overwrite an existing promoted note.
|
||||||
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
fm.set("wing", brain.Sanitise(opts.Wing))
|
||||||
|
fm.set("hall", opts.Hall)
|
||||||
|
if fm.get("created_at") == "" {
|
||||||
|
fm.set("created_at", now)
|
||||||
|
}
|
||||||
|
fm.set("promoted_at", now)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create wing dir: %w", err)
|
||||||
|
}
|
||||||
|
// Write-then-delete: the source survives any write failure.
|
||||||
|
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write promoted note: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(src); err != nil {
|
||||||
|
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, _ := filepath.Rel(brainDir, dest)
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
// Best-effort wiki upkeep — the note is already promoted.
|
||||||
|
_ = brain.BuildWingIndex(brainDir, opts.Wing)
|
||||||
|
_ = brain.AutoTunnel(brainDir, relSlash, body)
|
||||||
|
|
||||||
|
return relSlash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
||||||
|
func excerpt(s string, n int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) > n {
|
||||||
|
r = r[:n]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(r))
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeRaw(t *testing.T, brainDir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingEmptyWhenAbsent(t *testing.T) {
|
||||||
|
got, err := ListPending(t.TempDir())
|
||||||
|
require.NoError(t, err, "absent raw/ is not an error")
|
||||||
|
assert.Empty(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingReturnsOldestFirstWithExcerpt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-02-newer.md", "---\ncreated_at: 2026-06-02T00:00:00Z\n---\nNewer body here.\n")
|
||||||
|
writeRaw(t, dir, "2026-06-01-older.md", "---\ncreated_at: 2026-06-01T00:00:00Z\n---\nOlder body content.\n")
|
||||||
|
// non-md ignored
|
||||||
|
writeRaw(t, dir, "notes.txt", "ignore me")
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
assert.Equal(t, "2026-06-01-older.md", got[0].Filename, "oldest first")
|
||||||
|
assert.Equal(t, "2026-06-02-newer.md", got[1].Filename)
|
||||||
|
assert.Contains(t, got[0].Excerpt, "Older body content")
|
||||||
|
assert.NotContains(t, got[0].Excerpt, "---", "excerpt is body, not frontmatter")
|
||||||
|
assert.Positive(t, got[0].SizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteHappyPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-lejpa-decision.md",
|
||||||
|
"---\ncreated_at: 2026-06-01T09:00:00Z\ncustom_field: keep-me\n---\n# LeJEPA\n\nbody.\n")
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{
|
||||||
|
Filename: "2026-06-01-lejpa-decision.md", Wing: "jepa-fx", Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/jepa-fx/decisions/lejpa-decision.md", rel, "slug defaults to filename minus date prefix")
|
||||||
|
|
||||||
|
// Source deleted.
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-lejpa-decision.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "source removed after promote")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "wing: jepa-fx")
|
||||||
|
assert.Contains(t, s, "hall: decisions")
|
||||||
|
assert.Contains(t, s, "created_at: 2026-06-01T09:00:00Z", "original created_at preserved")
|
||||||
|
assert.Contains(t, s, "promoted_at:")
|
||||||
|
assert.Contains(t, s, "custom_field: keep-me", "custom frontmatter preserved")
|
||||||
|
assert.Contains(t, s, "# LeJEPA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteExplicitSlug(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts", Slug: "custom-slug"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/custom-slug.md", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteInvalidHallErrorsBeforeTouchingFS(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "garbage"})
|
||||||
|
require.Error(t, err)
|
||||||
|
// Source untouched.
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||||
|
assert.NoError(t, statErr, "invalid hall must not delete or move the source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteMissingSourceErrors(t *testing.T) {
|
||||||
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "ghost.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteSlugCollisionNoOverwrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Pre-existing target.
|
||||||
|
dest := filepath.Join(dir, "wiki", "a", "facts", "x.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "NEW\n")
|
||||||
|
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err, "collision must error, not overwrite")
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(dest)
|
||||||
|
assert.Equal(t, "EXISTING\n", string(got), "target not overwritten")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-06-01-x.md"))
|
||||||
|
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteRejectsTraversalFilename(t *testing.T) {
|
||||||
|
_, err := PromoteNote(t.TempDir(), PromoteOptions{Filename: "../escape.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteRebuildsWingIndex(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "2026-06-01-x.md", "---\ntitle: X Note\n---\nbody\n")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "2026-06-01-x.md", Wing: "a", Hall: "facts"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
idx, err := os.ReadFile(filepath.Join(dir, "wiki", "a", "_index.md"))
|
||||||
|
require.NoError(t, err, "wing _index regenerated")
|
||||||
|
assert.Contains(t, string(idx), "x", "promoted note appears in the index")
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same reason as WriteNote: the strip belongs in the primitive so every
|
||||||
|
// door inherits it.
|
||||||
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
opts.Content = clean
|
||||||
|
|
||||||
|
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,167 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileBuffer is a durable, restart-surviving audit buffer backed by a
|
||||||
|
// JSONL file: one {id, entry} record per line. It is the internal/public
|
||||||
|
// tier fallback when loki is unreachable. Confirm rewrites the file
|
||||||
|
// without the confirmed record, so a record is cleared only after its
|
||||||
|
// central write is confirmed.
|
||||||
|
//
|
||||||
|
// Access is serialised by a mutex; the buffer is low-throughput (only
|
||||||
|
// written during a loki outage), so a whole-file rewrite on Confirm is
|
||||||
|
// acceptable and keeps the on-disk format trivially correct.
|
||||||
|
type FileBuffer struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type bufferLine struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Entry capture.AuditEntry `json:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
||||||
|
// created if needed. The file itself is created lazily on first Append.
|
||||||
|
func NewFileBuffer(path string) (*FileBuffer, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create buffer dir: %w", err)
|
||||||
|
}
|
||||||
|
return &FileBuffer{path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writable reports whether the buffer file can be appended to. It probes
|
||||||
|
// by opening the file for append (creating it if absent) — the same
|
||||||
|
// operation Append performs — so Reserve's check matches Append's reality.
|
||||||
|
func (b *FileBuffer) Writable() error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append durably writes one audit record. The ID is derived from the
|
||||||
|
// content + timestamp so it is stable and unique per record.
|
||||||
|
func (b *FileBuffer) Append(e capture.AuditEntry) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
line := bufferLine{ID: recordID(e), Entry: e}
|
||||||
|
data, err := json.Marshal(line)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal buffer line: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending reads all buffered records. A missing file means none.
|
||||||
|
func (b *FileBuffer) Pending() ([]Buffered, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.readAllLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *FileBuffer) readAllLocked() ([]Buffered, error) {
|
||||||
|
f, err := os.Open(b.path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
var out []Buffered
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
raw := sc.Bytes()
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var l bufferLine
|
||||||
|
if err := json.Unmarshal(raw, &l); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse buffer line: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, Buffered(l))
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm removes a single record after its central write is confirmed, by
|
||||||
|
// rewriting the file without it. Unknown IDs are a no-op.
|
||||||
|
func (b *FileBuffer) Confirm(id string) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
all, err := b.readAllLocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := b.path + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
kept := 0
|
||||||
|
for _, rec := range all {
|
||||||
|
if rec.ID == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(bufferLine(rec))
|
||||||
|
if _, err := w.Write(append(data, '\n')); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept++
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Empty buffer → remove the file entirely so Pending sees nothing.
|
||||||
|
if kept == 0 {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return os.Remove(b.path)
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, b.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordID is a stable per-record identifier: sha256 of the principal,
|
||||||
|
// timestamp, and item list. Distinct captures never collide; the same
|
||||||
|
// buffered record always hashes the same.
|
||||||
|
func recordID(e capture.AuditEntry) string {
|
||||||
|
h := sha256.New()
|
||||||
|
_, _ = fmt.Fprintf(h, "%s|%s|%v|%s", e.Principal, e.Timestamp.UTC().Format("2006-01-02T15:04:05.000000000Z07:00"), e.Items, e.SessionRef)
|
||||||
|
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Central is the central audit substrate (loki). Ready is a cheap
|
||||||
|
// reachability probe used by the pre-write reserve; Push writes a record.
|
||||||
|
type Central interface {
|
||||||
|
Ready(ctx context.Context) error
|
||||||
|
Push(ctx context.Context, e capture.AuditEntry) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer is the durable local fallback for internal/public-tier records
|
||||||
|
// when the central sink is unreachable. It must survive process restart.
|
||||||
|
type Buffer interface {
|
||||||
|
// Writable reports whether the buffer can currently be appended to.
|
||||||
|
Writable() error
|
||||||
|
Append(e capture.AuditEntry) error
|
||||||
|
// Pending returns buffered records awaiting reconciliation, each with a
|
||||||
|
// stable ID used to Confirm (delete) it after a confirmed central write.
|
||||||
|
Pending() ([]Buffered, error)
|
||||||
|
Confirm(id string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffered is a buffered audit record plus its stable buffer ID.
|
||||||
|
type Buffered struct {
|
||||||
|
ID string
|
||||||
|
Entry capture.AuditEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier raises an out-of-band alert (ntfy) about a degraded state.
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, msg string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DegradingSink is the classification-aware AuditSink (§4.4):
|
||||||
|
//
|
||||||
|
// - central reachable → AuditCentral (all tiers).
|
||||||
|
// - central down + confidential → refuse (no buffer): confidential must
|
||||||
|
// be centrally auditable at write time.
|
||||||
|
// - central down + internal/public + buffer writable → AuditBuffered.
|
||||||
|
// - central down + (confidential, or buffer not writable) → refuse (floor).
|
||||||
|
//
|
||||||
|
// The decision is made in Reserve, before any write; Record then executes it.
|
||||||
|
type DegradingSink struct {
|
||||||
|
central Central
|
||||||
|
buffer Buffer
|
||||||
|
notifier Notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDegradingSink wires the central sink, durable buffer, and notifier.
|
||||||
|
func NewDegradingSink(central Central, buffer Buffer, notifier Notifier) *DegradingSink {
|
||||||
|
return &DegradingSink{central: central, buffer: buffer, notifier: notifier}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve decides, before any write, how the capture will be audited — or
|
||||||
|
// returns an error to refuse it.
|
||||||
|
func (d *DegradingSink) Reserve(ctx context.Context, level classification.Level) (capture.AuditOutcome, error) {
|
||||||
|
if err := d.central.Ready(ctx); err == nil {
|
||||||
|
return capture.AuditCentral, nil
|
||||||
|
}
|
||||||
|
// Central sink is down.
|
||||||
|
if level == classification.Confidential {
|
||||||
|
return 0, fmt.Errorf("confidential capture requires the central audit sink, which is unreachable")
|
||||||
|
}
|
||||||
|
if err := d.buffer.Writable(); err != nil {
|
||||||
|
// Floor: neither central nor local buffer can record the audit.
|
||||||
|
return 0, fmt.Errorf("audit floor: central sink down and local buffer unwritable: %w", err)
|
||||||
|
}
|
||||||
|
return capture.AuditBuffered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record persists the entry per the reserved outcome. For AuditBuffered it
|
||||||
|
// also fires the degraded-state alert.
|
||||||
|
func (d *DegradingSink) Record(ctx context.Context, e capture.AuditEntry, outcome capture.AuditOutcome) error {
|
||||||
|
switch outcome {
|
||||||
|
case capture.AuditBuffered:
|
||||||
|
if err := d.buffer.Append(e); err != nil {
|
||||||
|
return fmt.Errorf("buffer audit record: %w", err)
|
||||||
|
}
|
||||||
|
// Best-effort alert; the record is already durably buffered.
|
||||||
|
if d.notifier != nil {
|
||||||
|
_ = d.notifier.Notify(ctx, fmt.Sprintf(
|
||||||
|
"capture audit BUFFERED LOCALLY (loki unreachable) — principal=%s class=%s items=%d",
|
||||||
|
e.Principal, e.EffectiveClassification, len(e.Items)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return d.central.Push(ctx, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package audit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- fakes ---
|
||||||
|
|
||||||
|
type fakeCentral struct {
|
||||||
|
down bool
|
||||||
|
pushed []capture.AuditEntry
|
||||||
|
pushErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCentral) Ready(context.Context) error {
|
||||||
|
if f.down {
|
||||||
|
return errors.New("loki down")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCentral) Push(_ context.Context, e capture.AuditEntry) error {
|
||||||
|
if f.pushErr != nil {
|
||||||
|
return f.pushErr
|
||||||
|
}
|
||||||
|
f.pushed = append(f.pushed, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotifier struct{ msgs []string }
|
||||||
|
|
||||||
|
func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
|
||||||
|
f.msgs = append(f.msgs, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwritableBuffer always reports it cannot be written (floor condition).
|
||||||
|
type unwritableBuffer struct{}
|
||||||
|
|
||||||
|
func (unwritableBuffer) Writable() error { return errors.New("disk full") }
|
||||||
|
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
|
||||||
|
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
|
||||||
|
func (unwritableBuffer) Confirm(string) error { return nil }
|
||||||
|
|
||||||
|
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
||||||
|
t.Helper()
|
||||||
|
b, err := audit.NewFileBuffer(filepath.Join(t.TempDir(), "audit-buffer.jsonl"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(principal string) capture.AuditEntry {
|
||||||
|
return capture.AuditEntry{Principal: principal, EffectiveClassification: "internal", Items: []string{"insight:x"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reserve: classification-aware decision ---
|
||||||
|
|
||||||
|
func TestReserveCentralUpGrantsCentral(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{}, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
for _, lvl := range []classification.Level{classification.Public, classification.Internal, classification.Confidential} {
|
||||||
|
out, err := d.Reserve(context.Background(), lvl)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, capture.AuditCentral, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveConfidentialSinkDownRefuses(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
_, err := d.Reserve(context.Background(), classification.Confidential)
|
||||||
|
require.Error(t, err, "confidential + sink down → refuse, no buffer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveInternalSinkDownBuffers(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
out, err := d.Reserve(context.Background(), classification.Internal)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, capture.AuditBuffered, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveFloorRefusesWhenNothingCanRecord(t *testing.T) {
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, unwritableBuffer{}, &fakeNotifier{})
|
||||||
|
_, err := d.Reserve(context.Background(), classification.Internal)
|
||||||
|
require.Error(t, err, "central down AND buffer unwritable → floor refuse")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Record: executes the reserved outcome ---
|
||||||
|
|
||||||
|
func TestRecordCentralPushes(t *testing.T) {
|
||||||
|
c := &fakeCentral{}
|
||||||
|
d := audit.NewDegradingSink(c, newFileBuffer(t), &fakeNotifier{})
|
||||||
|
require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditCentral))
|
||||||
|
assert.Len(t, c.pushed, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordBufferedAppendsAndNotifies(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
nt := &fakeNotifier{}
|
||||||
|
d := audit.NewDegradingSink(&fakeCentral{down: true}, buf, nt)
|
||||||
|
require.NoError(t, d.Record(context.Background(), entry("p"), capture.AuditBuffered))
|
||||||
|
|
||||||
|
pending, err := buf.Pending()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, pending, 1)
|
||||||
|
assert.NotEmpty(t, nt.msgs, "degraded state alerts via ntfy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FileBuffer durability + Confirm ---
|
||||||
|
|
||||||
|
func TestFileBufferSurvivesRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "buf.jsonl")
|
||||||
|
b1, err := audit.NewFileBuffer(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, b1.Append(entry("p1")))
|
||||||
|
require.NoError(t, b1.Append(entry("p2")))
|
||||||
|
|
||||||
|
// "restart": a fresh FileBuffer over the same file sees the records.
|
||||||
|
b2, err := audit.NewFileBuffer(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pending, err := b2.Pending()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, pending, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileBufferConfirmRemovesOnlyThatRecord(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("keep")))
|
||||||
|
require.NoError(t, buf.Append(entry("drop")))
|
||||||
|
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
require.Len(t, pending, 2)
|
||||||
|
var dropID string
|
||||||
|
for _, p := range pending {
|
||||||
|
if p.Entry.Principal == "drop" {
|
||||||
|
dropID = p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NoError(t, buf.Confirm(dropID))
|
||||||
|
|
||||||
|
after, _ := buf.Pending()
|
||||||
|
require.Len(t, after, 1)
|
||||||
|
assert.Equal(t, "keep", after[0].Entry.Principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reconcile ---
|
||||||
|
|
||||||
|
func TestReconcileReplaysAndClearsOnlyAfterConfirmedWrite(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("a")))
|
||||||
|
require.NoError(t, buf.Append(entry("b")))
|
||||||
|
c := &fakeCentral{} // up
|
||||||
|
nt := &fakeNotifier{}
|
||||||
|
|
||||||
|
n, err := audit.Reconcile(context.Background(), c, buf, nt)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, n)
|
||||||
|
assert.Len(t, c.pushed, 2, "buffered records replayed to central")
|
||||||
|
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
assert.Empty(t, pending, "buffer cleared after confirmed central writes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileNoopWhenCentralDown(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("a")))
|
||||||
|
n, err := audit.Reconcile(context.Background(), &fakeCentral{down: true}, buf, &fakeNotifier{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, n)
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
assert.Len(t, pending, 1, "records stay buffered while central is down")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileKeepsRecordWhenPushFails(t *testing.T) {
|
||||||
|
buf := newFileBuffer(t)
|
||||||
|
require.NoError(t, buf.Append(entry("a")))
|
||||||
|
// Ready ok but Push fails → record must remain buffered (not lost).
|
||||||
|
c := &fakeCentral{pushErr: errors.New("push rejected")}
|
||||||
|
n, err := audit.Reconcile(context.Background(), c, buf, &fakeNotifier{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, n)
|
||||||
|
pending, _ := buf.Pending()
|
||||||
|
assert.Len(t, pending, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LokiCentral pushes capture audit records to a Grafana Loki instance via
|
||||||
|
// its push API, and probes readiness via /ready. It is the central audit
|
||||||
|
// substrate behind DegradingSink.
|
||||||
|
type LokiCentral struct {
|
||||||
|
baseURL string
|
||||||
|
labels map[string]string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLokiCentral constructs a LokiCentral for the given base URL (e.g.
|
||||||
|
// http://loki:3100). Returns nil when baseURL is empty so callers can
|
||||||
|
// treat missing config as "no central sink" with a single nil check.
|
||||||
|
func NewLokiCentral(baseURL string) *LokiCentral {
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &LokiCentral{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
labels: map[string]string{"service": "brain-capture", "kind": "audit"},
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready probes Loki's readiness endpoint.
|
||||||
|
func (l *LokiCentral) Ready(ctx context.Context) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.baseURL+"/ready", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := l.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loki not ready: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("loki not ready: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushPayload is the Loki push API body: one stream, one entry whose line
|
||||||
|
// is the JSON-encoded audit record.
|
||||||
|
type pushPayload struct {
|
||||||
|
Streams []lokiStream `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lokiStream struct {
|
||||||
|
Stream map[string]string `json:"stream"`
|
||||||
|
Values [][2]string `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push writes one audit record to Loki as a structured log line.
|
||||||
|
func (l *LokiCentral) Push(ctx context.Context, e capture.AuditEntry) error {
|
||||||
|
line, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal audit entry: %w", err)
|
||||||
|
}
|
||||||
|
ts := e.Timestamp
|
||||||
|
if ts.IsZero() {
|
||||||
|
ts = time.Now()
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(pushPayload{Streams: []lokiStream{{
|
||||||
|
Stream: l.labels,
|
||||||
|
Values: [][2]string{{strconv.FormatInt(ts.UTC().UnixNano(), 10), string(line)}},
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
l.baseURL+"/loki/api/v1/push", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := l.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loki push: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("loki push: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package audit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLokiReadyAndPush(t *testing.T) {
|
||||||
|
var pushBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/ready":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case "/loki/api/v1/push":
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
pushBody = string(b)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := audit.NewLokiCentral(srv.URL)
|
||||||
|
require.NotNil(t, c)
|
||||||
|
require.NoError(t, c.Ready(context.Background()))
|
||||||
|
|
||||||
|
err := c.Push(context.Background(), capture.AuditEntry{
|
||||||
|
Principal: "koala-cli", EffectiveClassification: "internal", Items: []string{"insight:x"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, pushBody, "streams")
|
||||||
|
assert.Contains(t, pushBody, "koala-cli", "audit entry serialised into the loki line")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiReadyFailsWhenDown(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
require.Error(t, audit.NewLokiCentral(srv.URL).Ready(context.Background()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLokiNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, audit.NewLokiCentral(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNtfyNotify(t *testing.T) {
|
||||||
|
var gotBody, gotAuth string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
n := audit.NewNtfyNotifier(srv.URL, "ntfy-token")
|
||||||
|
require.NotNil(t, n)
|
||||||
|
require.NoError(t, n.Notify(context.Background(), "audit buffered locally"))
|
||||||
|
assert.Contains(t, gotBody, "audit buffered locally")
|
||||||
|
assert.Equal(t, "Bearer ntfy-token", gotAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNtfyDoesNotLeakTokenOnError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
err := audit.NewNtfyNotifier(srv.URL, "secret-token").Notify(context.Background(), "x")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, strings.Contains(err.Error(), "secret-token"), "token must not leak into errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNtfyNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, audit.NewNtfyNotifier("", "tok"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NtfyNotifier posts alerts to an ntfy topic URL. Used to surface a
|
||||||
|
// degraded audit state (records buffered locally during a loki outage).
|
||||||
|
type NtfyNotifier struct {
|
||||||
|
topicURL string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL
|
||||||
|
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
|
||||||
|
// protected ntfy instances; it is held here and only sent in the
|
||||||
|
// Authorization header, never logged. Returns nil when topicURL is empty.
|
||||||
|
func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
|
||||||
|
if topicURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &NtfyNotifier{
|
||||||
|
topicURL: strings.TrimRight(topicURL, "/"),
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify posts a message to the ntfy topic.
|
||||||
|
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Title", "brain-capture audit degraded")
|
||||||
|
req.Header.Set("Priority", "high")
|
||||||
|
req.Header.Set("Tags", "warning,brain")
|
||||||
|
if n.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+n.token)
|
||||||
|
}
|
||||||
|
resp, err := n.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ntfy notify: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("ntfy notify: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reconcile replays locally-buffered audit records to the central sink
|
||||||
|
// when it is reachable again. A record is removed from the buffer ONLY
|
||||||
|
// after its central write is confirmed, so a crash mid-reconcile re-plays
|
||||||
|
// rather than loses. Returns the number of records reconciled.
|
||||||
|
//
|
||||||
|
// A no-op (0, nil) when the central sink is still unreachable or the
|
||||||
|
// buffer is empty.
|
||||||
|
func Reconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier) (int, error) {
|
||||||
|
if err := central.Ready(ctx); err != nil {
|
||||||
|
return 0, nil // still down; try again next tick
|
||||||
|
}
|
||||||
|
pending, err := buffer.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read buffer: %w", err)
|
||||||
|
}
|
||||||
|
reconciled := 0
|
||||||
|
for _, rec := range pending {
|
||||||
|
if err := central.Push(ctx, rec.Entry); err != nil {
|
||||||
|
// Central went away mid-drain; stop and keep the rest buffered.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := buffer.Confirm(rec.ID); err != nil {
|
||||||
|
return reconciled, fmt.Errorf("confirm buffered record %s: %w", rec.ID, err)
|
||||||
|
}
|
||||||
|
reconciled++
|
||||||
|
}
|
||||||
|
if reconciled > 0 && notifier != nil {
|
||||||
|
_ = notifier.Notify(ctx, fmt.Sprintf("reconciled %d buffered capture audit record(s) to loki", reconciled))
|
||||||
|
}
|
||||||
|
return reconciled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartReconcile runs Reconcile on a ticker until ctx is cancelled. It is
|
||||||
|
// the recovery half of the degrade-and-buffer path; pair it with a
|
||||||
|
// DegradingSink sharing the same buffer + central.
|
||||||
|
func StartReconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = time.Minute
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if n, err := Reconcile(ctx, central, buffer, notifier); err != nil {
|
||||||
|
slog.Warn("audit reconcile failed", "err", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
slog.Info("audit reconcile", "reconciled", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Package audit provides AuditSink implementations for the capture
|
||||||
|
// capability (I5). This file ships the minimal slog-backed sink used in
|
||||||
|
// #53: it emits the request-level audit record to structured logs, which
|
||||||
|
// the alloy/loki substrate already scrapes. The classification-aware
|
||||||
|
// degradation/refusal sink (confidential fails closed, internal buffers +
|
||||||
|
// reconciles) lands in #54 and replaces this behind the same interface.
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlogSink records audit entries to an slog.Logger. It never fails and is
|
||||||
|
// always centrally available, so its Reserve always grants AuditCentral —
|
||||||
|
// it does not exercise the I5 degradation/floor. That is DegradingSink's
|
||||||
|
// job (loki + durable buffer). SlogSink is the default for deployments
|
||||||
|
// without a loki endpoint configured. A nil logger ⇒ slog.Default().
|
||||||
|
type SlogSink struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSlogSink constructs a SlogSink. nil logger ⇒ slog.Default().
|
||||||
|
func NewSlogSink(logger *slog.Logger) *SlogSink {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &SlogSink{logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve always grants central recording — slog is always available.
|
||||||
|
func (s *SlogSink) Reserve(_ context.Context, _ classification.Level) (capture.AuditOutcome, error) {
|
||||||
|
return capture.AuditCentral, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record emits the audit entry at info level. Security events, when
|
||||||
|
// present, are logged at warn level so they surface independently of the
|
||||||
|
// routine audit stream.
|
||||||
|
func (s *SlogSink) Record(_ context.Context, e capture.AuditEntry, _ capture.AuditOutcome) error {
|
||||||
|
s.logger.Info("capture audit",
|
||||||
|
"principal", e.Principal,
|
||||||
|
"actor", e.Actor,
|
||||||
|
"harness", e.Harness,
|
||||||
|
"session_ref", e.SessionRef,
|
||||||
|
"classification", e.EffectiveClassification,
|
||||||
|
"items", e.Items,
|
||||||
|
"ts", e.Timestamp,
|
||||||
|
)
|
||||||
|
for _, ev := range e.SecurityEvents {
|
||||||
|
s.logger.Warn("capture security event", "principal", e.Principal, "event", ev)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package audit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSlogSinkRecordsEntryAndSecurityEvents(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
sink := audit.NewSlogSink(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||||
|
|
||||||
|
err := sink.Record(context.Background(), capture.AuditEntry{
|
||||||
|
Principal: "koala-cli",
|
||||||
|
Harness: "claude-code",
|
||||||
|
EffectiveClassification: "confidential",
|
||||||
|
Items: []string{"insight:wiki/a/facts/x.md"},
|
||||||
|
SecurityEvents: []string{"asserted-vs-derived origin mismatch"},
|
||||||
|
}, capture.AuditCentral)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
assert.Contains(t, out, "capture audit")
|
||||||
|
assert.Contains(t, out, "koala-cli")
|
||||||
|
assert.Contains(t, out, "confidential")
|
||||||
|
assert.Contains(t, out, "capture security event")
|
||||||
|
assert.Contains(t, out, "asserted-vs-derived origin mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlogSinkNilLoggerDefaults(t *testing.T) {
|
||||||
|
// nil logger must not panic.
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
_ = audit.NewSlogSink(nil).Record(context.Background(), capture.AuditEntry{}, capture.AuditCentral)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validator validates Bearer JWTs issued by a Dex (OIDC) authorization server.
|
|
||||||
// Audience is optional; leave empty to skip audience validation.
|
|
||||||
type Validator struct {
|
|
||||||
issuer string
|
|
||||||
audience string
|
|
||||||
jwksURI string
|
|
||||||
cache *jwk.Cache
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewValidator fetches the OIDC discovery document from issuerURL, extracts
|
|
||||||
// jwks_uri, seeds the JWKS cache, and returns a ready Validator.
|
|
||||||
// If DEX_ISSUER_URL is not set the caller should pass "" and skip construction.
|
|
||||||
func NewValidator(issuerURL, audience string) (*Validator, error) {
|
|
||||||
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration") //nolint:noctx
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("oidc discovery: status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var doc struct {
|
|
||||||
JWKSURI string `json:"jwks_uri"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
|
||||||
}
|
|
||||||
if doc.JWKSURI == "" {
|
|
||||||
return nil, fmt.Errorf("oidc discovery: empty jwks_uri")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
cache := jwk.NewCache(ctx)
|
|
||||||
if err := cache.Register(doc.JWKSURI, jwk.WithMinRefreshInterval(time.Hour)); err != nil {
|
|
||||||
return nil, fmt.Errorf("register jwks cache: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
|
||||||
return nil, fmt.Errorf("initial jwks fetch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Validator{
|
|
||||||
issuer: issuerURL,
|
|
||||||
audience: audience,
|
|
||||||
jwksURI: doc.JWKSURI,
|
|
||||||
cache: cache,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate parses and validates rawToken. Returns the subject claim on success.
|
|
||||||
func (v *Validator) Validate(ctx context.Context, rawToken string) (string, error) {
|
|
||||||
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("get jwks: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []jwt.ParseOption{
|
|
||||||
jwt.WithKeySet(keySet),
|
|
||||||
jwt.WithValidate(true),
|
|
||||||
jwt.WithIssuer(v.issuer),
|
|
||||||
}
|
|
||||||
if v.audience != "" {
|
|
||||||
opts = append(opts, jwt.WithAudience(v.audience))
|
|
||||||
}
|
|
||||||
|
|
||||||
tok, err := jwt.ParseString(rawToken, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("validate jwt: %w", err)
|
|
||||||
}
|
|
||||||
return tok.Subject(), nil
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package auth_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testKeys struct {
|
|
||||||
priv jwk.Key
|
|
||||||
pub jwk.Key
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateRSAKeys(t *testing.T) testKeys {
|
|
||||||
t.Helper()
|
|
||||||
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
priv, err := jwk.FromRaw(raw)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, priv.Set(jwk.KeyIDKey, "test-kid"))
|
|
||||||
require.NoError(t, priv.Set(jwk.AlgorithmKey, jwa.RS256))
|
|
||||||
|
|
||||||
pub, err := jwk.PublicKeyOf(priv)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return testKeys{priv: priv, pub: pub}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mockOIDCServer(t *testing.T, keys testKeys) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
set := jwk.NewSet()
|
|
||||||
require.NoError(t, set.AddKey(keys.pub))
|
|
||||||
jwksBytes, err := json.Marshal(set)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
var srv *httptest.Server
|
|
||||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"issuer": srv.URL,
|
|
||||||
"jwks_uri": srv.URL + "/jwks",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write(jwksBytes)
|
|
||||||
})
|
|
||||||
srv = httptest.NewServer(mux)
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
func signToken(t *testing.T, keys testKeys, issuer, audience, subject string, exp time.Time) string {
|
|
||||||
t.Helper()
|
|
||||||
b := jwt.NewBuilder().
|
|
||||||
Issuer(issuer).
|
|
||||||
Subject(subject).
|
|
||||||
Expiration(exp)
|
|
||||||
if audience != "" {
|
|
||||||
b = b.Audience([]string{audience})
|
|
||||||
}
|
|
||||||
tok, err := b.Build()
|
|
||||||
require.NoError(t, err)
|
|
||||||
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
|
||||||
require.NoError(t, err)
|
|
||||||
return string(signed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidator(t *testing.T) {
|
|
||||||
keys := generateRSAKeys(t)
|
|
||||||
srv := mockOIDCServer(t, keys)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
v, err := auth.NewValidator(srv.URL, "brain")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
token string
|
|
||||||
wantSub string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "valid jwt",
|
|
||||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)),
|
|
||||||
wantSub: "test-user",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "expired jwt",
|
|
||||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(-time.Hour)),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong issuer",
|
|
||||||
token: signToken(t, keys, "https://evil.example.com", "brain", "test-user", time.Now().Add(time.Hour)),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong audience",
|
|
||||||
token: signToken(t, keys, srv.URL, "other-service", "test-user", time.Now().Add(time.Hour)),
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tampered token",
|
|
||||||
token: signToken(t, keys, srv.URL, "brain", "test-user", time.Now().Add(time.Hour)) + "tampered",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "not a jwt",
|
|
||||||
token: "not-a-jwt",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
sub, err := v.Validate(ctx, tc.token)
|
|
||||||
if tc.wantErr {
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Empty(t, sub)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tc.wantSub, sub)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewValidator_NoAudience(t *testing.T) {
|
|
||||||
keys := generateRSAKeys(t)
|
|
||||||
srv := mockOIDCServer(t, keys)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
v, err := auth.NewValidator(srv.URL, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Token without audience passes when audience validation is disabled.
|
|
||||||
tok, err := jwt.NewBuilder().
|
|
||||||
Issuer(srv.URL).
|
|
||||||
Subject("sub").
|
|
||||||
Expiration(time.Now().Add(time.Hour)).
|
|
||||||
Build()
|
|
||||||
require.NoError(t, err)
|
|
||||||
signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, keys.priv))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sub, err := v.Validate(ctx, string(signed))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "sub", sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewValidator_BadDiscoveryURL(t *testing.T) {
|
|
||||||
_, err := auth.NewValidator("http://127.0.0.1:1", "brain")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProtectedResourceHandler returns an RFC 9728 oauth-protected-resource metadata
|
|
||||||
// handler. Mount at GET /.well-known/oauth-protected-resource (no auth required).
|
|
||||||
func ProtectedResourceHandler(resourceURL, issuerURL string) http.HandlerFunc {
|
|
||||||
type metadata struct {
|
|
||||||
Resource string `json:"resource"`
|
|
||||||
AuthorizationServers []string `json:"authorization_servers"`
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(metadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{issuerURL},
|
|
||||||
})
|
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package auth_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/auth"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestProtectedResourceHandler(t *testing.T) {
|
|
||||||
h := auth.ProtectedResourceHandler("https://brain-mcp.d-ma.be", "https://auth.d-ma.be")
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
h(rr, req)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rr.Code)
|
|
||||||
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
|
||||||
|
|
||||||
var body map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
|
|
||||||
assert.Equal(t, "https://brain-mcp.d-ma.be", body["resource"])
|
|
||||||
servers := body["authorization_servers"].([]any)
|
|
||||||
assert.Equal(t, "https://auth.d-ma.be", servers[0])
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wikilinkRE matches [[target]] and [[target|Display Text]].
|
||||||
|
var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`)
|
||||||
|
|
||||||
|
// fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a
|
||||||
|
// syntax example rather than a link. Same exclusion brain-gardener's audit
|
||||||
|
// applies on the read side, so the write gate and the audit agree on what
|
||||||
|
// counts as a link at all.
|
||||||
|
var (
|
||||||
|
fencedCodeRE = regexp.MustCompile("(?s)```.*?```")
|
||||||
|
inlineCodeRE = regexp.MustCompile("`[^`\n]*`")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StripUnresolvedLinks unwraps every [[wikilink]] in content whose target
|
||||||
|
// resolves to no entry under knowledge/ or wiki/, leaving the link's display
|
||||||
|
// text (or its target) as plain prose. It returns the rewritten content and
|
||||||
|
// the sorted, de-duplicated targets it unwrapped.
|
||||||
|
//
|
||||||
|
// WHY UNWRAP RATHER THAN REJECT THE WRITE
|
||||||
|
// Agents write [[...]] around anything that feels related — a skill name, a
|
||||||
|
// repo, an agent-local memory file — and none of those are brain entries.
|
||||||
|
// Every one of those becomes a permanent [high] broken-link finding that no
|
||||||
|
// deterministic Fixer can repair, because there is no target to repoint to:
|
||||||
|
// the brain-audit digest grew from 19 to 63 high findings between 2026-08-26
|
||||||
|
// and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write
|
||||||
|
// instead would push the failure onto claudewatcher's unattended ingest path,
|
||||||
|
// where a refused write is a lost learning. Unwrapping keeps the author's
|
||||||
|
// words, keeps the write, and keeps the graph honest.
|
||||||
|
//
|
||||||
|
// An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir)
|
||||||
|
// returns content unchanged: nothing resolving is a bootstrap state, not
|
||||||
|
// evidence that every target was invented.
|
||||||
|
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
|
||||||
|
index, err := entryIndex(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(index) == 0 {
|
||||||
|
return content, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := codeSpans(content)
|
||||||
|
stripped := map[string]struct{}{}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
last := 0
|
||||||
|
for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) {
|
||||||
|
start, end := m[0], m[1]
|
||||||
|
if overlaps(start, end, code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(content[m[2]:m[3]])
|
||||||
|
if _, ok := index[linkKey(target)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
display := target
|
||||||
|
if m[4] >= 0 {
|
||||||
|
if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" {
|
||||||
|
display = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(content[last:start])
|
||||||
|
b.WriteString(display)
|
||||||
|
last = end
|
||||||
|
stripped[target] = struct{}{}
|
||||||
|
}
|
||||||
|
if last == 0 {
|
||||||
|
return content, nil, nil
|
||||||
|
}
|
||||||
|
b.WriteString(content[last:])
|
||||||
|
|
||||||
|
targets := make([]string, 0, len(stripped))
|
||||||
|
for t := range stripped {
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
sort.Strings(targets)
|
||||||
|
return b.String(), targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkedTrees are the two trees that hold linkable entries — the same pair
|
||||||
|
// package notes admits under ScopeNotes. Duplicated rather than imported
|
||||||
|
// because that list is unexported there and this is a read-only mirror.
|
||||||
|
var linkedTrees = []string{"knowledge", "wiki"}
|
||||||
|
|
||||||
|
// entryIndex maps every form a wikilink may name an entry by — its bare slug
|
||||||
|
// and its path form (wiki/<wing>/<hall>/<slug>.md -> wing/hall/slug) — to a
|
||||||
|
// present marker. Mirrors brain-gardener's resolver so an accepted link is
|
||||||
|
// one the audit will also resolve.
|
||||||
|
func entryIndex(brainDir string) (map[string]struct{}, error) {
|
||||||
|
index := map[string]struct{}{}
|
||||||
|
for _, dir := range linkedTrees {
|
||||||
|
root := filepath.Join(brainDir, dir)
|
||||||
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
|
||||||
|
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("walk %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkKey normalises a link target for index lookup: an exact path form is
|
||||||
|
// tried as written, and anything else falls back to its final segment.
|
||||||
|
func linkKey(target string) string {
|
||||||
|
return strings.TrimSuffix(target, ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeSpans returns the byte ranges of fenced blocks and inline code spans.
|
||||||
|
func codeSpans(content string) [][2]int {
|
||||||
|
var spans [][2]int
|
||||||
|
for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) {
|
||||||
|
spans = append(spans, [2]int{m[0], m[1]})
|
||||||
|
}
|
||||||
|
for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) {
|
||||||
|
spans = append(spans, [2]int{m[0], m[1]})
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlaps reports whether [start,end) intersects any span.
|
||||||
|
func overlaps(start, end int, spans [][2]int) bool {
|
||||||
|
for _, s := range spans {
|
||||||
|
if start < s[1] && end > s[0] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCorpus writes the two entries every linkcheck case resolves against.
|
||||||
|
func seedCorpus(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
seedNote(t, dir, "wiki/homelab/facts/real-entry.md",
|
||||||
|
"---\nwing: homelab\nhall: facts\ntitle: 'Real entry'\n---\n\nbody.\n")
|
||||||
|
seedNote(t, dir, "knowledge/legacy-entry.md",
|
||||||
|
"---\ntitle: 'Legacy entry'\n---\n\nbody.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUnresolvedLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
stripped []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bare slug that resolves is kept",
|
||||||
|
body: "See [[real-entry]] for the rule.",
|
||||||
|
want: "See [[real-entry]] for the rule.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path-style target that resolves is kept",
|
||||||
|
body: "See [[homelab/facts/real-entry]].",
|
||||||
|
want: "See [[homelab/facts/real-entry]].",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "knowledge/ entry resolves by bare slug",
|
||||||
|
body: "See [[legacy-entry]].",
|
||||||
|
want: "See [[legacy-entry]].",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable target is unwrapped to plain text",
|
||||||
|
body: "Related: [[secret-hygiene]].",
|
||||||
|
want: "Related: secret-hygiene.",
|
||||||
|
stripped: []string{"secret-hygiene"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable aliased target keeps its display text",
|
||||||
|
body: "Related: [[feedback_check_host_not_assume|check the host]].",
|
||||||
|
want: "Related: check the host.",
|
||||||
|
stripped: []string{"feedback_check_host_not_assume"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "link inside a fenced code block is left alone",
|
||||||
|
body: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||||
|
want: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "link inside an inline code span is left alone",
|
||||||
|
body: "Write `[[wing/hall/slug]]` to link.",
|
||||||
|
want: "Write `[[wing/hall/slug]]` to link.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "every unresolvable target is reported once, sorted",
|
||||||
|
body: "[[zeta-gap]] and [[alpha-gap]] and [[zeta-gap]] again.",
|
||||||
|
want: "zeta-gap and alpha-gap and zeta-gap again.",
|
||||||
|
stripped: []string{"alpha-gap", "zeta-gap"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedCorpus(t, dir)
|
||||||
|
|
||||||
|
got, stripped, err := brain.StripUnresolvedLinks(dir, tc.body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
assert.Equal(t, tc.stripped, stripped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUnresolvedLinks_MissingBrainDirIsNotAGate(t *testing.T) {
|
||||||
|
// A brain dir with no knowledge//wiki/ tree yet must not turn every link
|
||||||
|
// into an unresolved one — an empty corpus is a bootstrap state, not
|
||||||
|
// evidence that the author invented the target.
|
||||||
|
got, stripped, err := brain.StripUnresolvedLinks(t.TempDir(), "See [[real-entry]].")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "See [[real-entry]].", got)
|
||||||
|
assert.Nil(t, stripped)
|
||||||
|
}
|
||||||
@@ -54,11 +54,24 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
||||||
needle := strings.ToLower(strings.TrimSpace(title))
|
title = strings.TrimSpace(title)
|
||||||
if needle == "" {
|
if title == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
idx := strings.Index(lowerContent, needle)
|
|
||||||
|
// A one-word title has to match the prose case-exactly. Matched
|
||||||
|
// case-insensitively, it links every entry that happens to use the
|
||||||
|
// word: "WRONG" (wiki/telos/decisions/wrong.md) collected 185 inbound
|
||||||
|
// See-also links and "Status" 136, making the two most-linked nodes in
|
||||||
|
// the graph artifacts of the English language rather than references.
|
||||||
|
// Multi-word titles keep the case-insensitive match — "risk tier
|
||||||
|
// middleware" in prose is a real mention however it is capitalised,
|
||||||
|
// and a three-word collision is not an accident.
|
||||||
|
haystack, needle := lowerContent, strings.ToLower(title)
|
||||||
|
if isSingleWord(title) {
|
||||||
|
haystack, needle = content, title
|
||||||
|
}
|
||||||
|
idx := strings.Index(haystack, needle)
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,7 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
out = append(out, TunnelCandidate{
|
out = append(out, TunnelCandidate{
|
||||||
TargetPath: filepath.ToSlash(rel),
|
TargetPath: filepath.ToSlash(rel),
|
||||||
MatchedTerm: title,
|
MatchedTerm: title,
|
||||||
Exact: isWholeWord(lowerContent, idx, len(needle)),
|
Exact: isWholeWord(haystack, idx, len(needle)),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -79,6 +92,11 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSingleWord reports whether title is one whitespace-free token.
|
||||||
|
func isSingleWord(title string) bool {
|
||||||
|
return !strings.ContainsAny(title, " \t")
|
||||||
|
}
|
||||||
|
|
||||||
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
||||||
// bounded by non-alphanumeric characters (or string edges).
|
// bounded by non-alphanumeric characters (or string edges).
|
||||||
func isWholeWord(s string, idx, n int) bool {
|
func isWholeWord(s string, idx, n int) bool {
|
||||||
|
|||||||
@@ -102,16 +102,29 @@ func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
seedNote(t, dir, "wiki/x/facts/routing.md",
|
seedNote(t, dir, "wiki/x/facts/routing-table.md",
|
||||||
"---\ntitle: Routing\n---\nbody.\n")
|
"---\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||||
|
|
||||||
// Substring of title appears in content, but not as a whole word.
|
// Substring of title appears in content, but not as a whole word.
|
||||||
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
candidates, err := brain.DetectTunnels(dir, "the routing tables were rebuilt")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, candidates, 1)
|
require.Len(t, candidates, 1)
|
||||||
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-word title whose only appearance is lowercase inside another word is
|
||||||
|
// not even a candidate: it would fill the review file with English rather than
|
||||||
|
// with references.
|
||||||
|
func TestDetectTunnels_SingleWordSubstringIsNotACandidate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||||
|
"---\ntitle: Routing\n---\nbody.\n")
|
||||||
|
|
||||||
|
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
||||||
@@ -126,20 +139,21 @@ func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
|||||||
|
|
||||||
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
// Existing note in a different wing whose title is "Routing".
|
// Existing note in a different wing whose title is "Routing Table".
|
||||||
seedNote(t, dir, "wiki/other/facts/routing.md",
|
seedNote(t, dir, "wiki/other/facts/routing-table.md",
|
||||||
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
|
"---\nwing: other\nhall: facts\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||||
// Source note in another wing whose body mentions "rerouting" (substring match only).
|
// Source note in another wing whose body mentions "routing tables"
|
||||||
|
// (substring match only — the title is not there as a whole word).
|
||||||
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
||||||
"---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n")
|
"---\nwing: jepa-fx\nhall: facts\n---\nrouting tables rebuilt\n")
|
||||||
|
|
||||||
require.NoError(t, brain.AutoTunnel(dir,
|
require.NoError(t, brain.AutoTunnel(dir,
|
||||||
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
|
"wiki/jepa-fx/facts/new.md", "routing tables rebuilt"))
|
||||||
|
|
||||||
// Source must not get auto-linked (fuzzy).
|
// Source must not get auto-linked (fuzzy).
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, string(got), "[[other/facts/routing]]")
|
assert.NotContains(t, string(got), "[[other/facts/routing-table]]")
|
||||||
|
|
||||||
// Candidates file must list the pair.
|
// Candidates file must list the pair.
|
||||||
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
||||||
@@ -148,7 +162,7 @@ func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
|||||||
body, err := os.ReadFile(matches[0])
|
body, err := os.ReadFile(matches[0])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
||||||
assert.Contains(t, string(body), "wiki/other/facts/routing.md")
|
assert.Contains(t, string(body), "wiki/other/facts/routing-table.md")
|
||||||
assert.Contains(t, string(body), "Routing")
|
assert.Contains(t, string(body), "Routing")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +189,35 @@ func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) {
|
|||||||
assert.Contains(t, s, "[[a/facts/old]]")
|
assert.Contains(t, s, "[[a/facts/old]]")
|
||||||
assert.Contains(t, s, "[[b/facts/y]]")
|
assert.Contains(t, s, "[[b/facts/y]]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-word title matched case-insensitively links every entry that happens
|
||||||
|
// to use that word. Live cost before this rule: 185 entries auto-linked to
|
||||||
|
// wiki/telos/decisions/wrong.md (title "WRONG") and 136 to .../status.md,
|
||||||
|
// making the two most-linked nodes in the graph pure artifacts.
|
||||||
|
func TestDetectTunnels_SingleWordTitleNeedsAnExactCaseMatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/telos/decisions/wrong.md",
|
||||||
|
"---\nwing: telos\nhall: decisions\ntitle: WRONG\n---\n# WRONG\n\nRetired beliefs.\n")
|
||||||
|
|
||||||
|
got, err := brain.DetectTunnels(dir, "The obvious fix was to reject the write. Wrong here.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, c := range got {
|
||||||
|
assert.False(t, c.Exact, "a lowercase prose word must not auto-link a one-word title: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = brain.DetectTunnels(dir, "The beliefs listed under WRONG were retired.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.True(t, got[0].Exact, "the title's own casing appearing in prose is a real reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTunnels_MultiWordTitleStaysCaseInsensitive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/homelab/facts/risk-tier.md",
|
||||||
|
"---\nwing: homelab\nhall: facts\ntitle: 'Risk Tier Middleware'\n---\nbody\n")
|
||||||
|
|
||||||
|
got, err := brain.DetectTunnels(dir, "We rebuilt the risk tier middleware last week.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.True(t, got[0].Exact)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Package brainstore is the concrete BrainStore: the single shared
|
||||||
|
// implementation of the #45 write/update/get verbs, used by BOTH the MCP
|
||||||
|
// handlers and the capture use-case so there is one implementation, not
|
||||||
|
// two (the Clean-Architecture / DRY payoff of #51).
|
||||||
|
//
|
||||||
|
// It composes the file-level primitives in package api (WriteNote,
|
||||||
|
// UpdateNote, ReadNote — the read-after-write contract) with the wiki
|
||||||
|
// upkeep that must accompany a write: wing _index rebuild, cross-wing
|
||||||
|
// auto-tunnel, and graph re-index. Embedding refresh is intentionally
|
||||||
|
// out-of-band (mtime-driven vectorstore.Sync) and not triggered here —
|
||||||
|
// see the brain note on out-of-band sync.
|
||||||
|
package brainstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store implements capture.BrainStore against a brain directory on disk,
|
||||||
|
// optionally re-indexing each write into the knowledge graph.
|
||||||
|
type Store struct {
|
||||||
|
brainDir string
|
||||||
|
graph graphsync.Store // nil = graph re-index disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Store bound to brainDir with graph indexing disabled.
|
||||||
|
func New(brainDir string) *Store {
|
||||||
|
return &Store{brainDir: brainDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGraph enables graph re-index on every write/update. nil disables it.
|
||||||
|
func (s *Store) WithGraph(g graphsync.Store) *Store {
|
||||||
|
s.graph = g
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write creates a brain note and returns its read-after-write handle.
|
||||||
|
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||||
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
n.Content = content
|
||||||
|
|
||||||
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
|
Content: n.Content,
|
||||||
|
Filename: n.Filename,
|
||||||
|
Type: n.Type,
|
||||||
|
Domain: n.Domain,
|
||||||
|
Wing: n.Wing,
|
||||||
|
Hall: n.Hall,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
s.wikiUpkeep(relPath, n.Wing, n.Content)
|
||||||
|
s.indexInGraph(ctx, "brain_write", relPath)
|
||||||
|
|
||||||
|
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||||
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Unlinked: unlinked}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update supersedes an existing note in place. slug may be a bare slug
|
||||||
|
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||||
|
// contains a slash). It never creates — a missing target is an error.
|
||||||
|
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||||
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
n.Content = content
|
||||||
|
|
||||||
|
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||||
|
if strings.Contains(slug, "/") {
|
||||||
|
opts.Path = slug
|
||||||
|
} else {
|
||||||
|
opts.Wing, opts.Hall, opts.Slug = n.Wing, n.Hall, slug
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
if wing := wingFromRelPath(relPath); wing != "" {
|
||||||
|
s.wikiUpkeep(relPath, wing, n.Content)
|
||||||
|
}
|
||||||
|
s.indexInGraph(ctx, "brain_update", relPath)
|
||||||
|
|
||||||
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true, Unlinked: unlinked}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fetches a note by id/path — the read-after-write confirmation
|
||||||
|
// primitive (a direct fetch, never a semantic query).
|
||||||
|
func (s *Store) Get(_ context.Context, id string) (capture.StoredNote, error) {
|
||||||
|
fm, body, hash, err := api.ReadNote(s.brainDir, id)
|
||||||
|
if err != nil {
|
||||||
|
return capture.StoredNote{}, err
|
||||||
|
}
|
||||||
|
return capture.StoredNote{ID: id, Path: id, ContentHash: hash, Frontmatter: fm, Body: body}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wikiUpkeep rebuilds the wing _index and re-tunnels cross-wing matches
|
||||||
|
// when a note lands in the structured wiki. Both are best-effort: the
|
||||||
|
// note is already written, so a failure here is logged, not propagated.
|
||||||
|
func (s *Store) wikiUpkeep(relPath, wing, content string) {
|
||||||
|
if wing == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
||||||
|
slog.Warn("brainstore: auto-index failed", "wing", wing, "err", err)
|
||||||
|
}
|
||||||
|
if err := brain.AutoTunnel(s.brainDir, relPath, content); err != nil {
|
||||||
|
slog.Warn("brainstore: auto-tunnel failed", "src", relPath, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexInGraph re-indexes a written doc into the graph, best-effort.
|
||||||
|
func (s *Store) indexInGraph(ctx context.Context, op, relPath string) {
|
||||||
|
if s.graph == nil || relPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := graphsync.IndexDoc(ctx, s.graph, s.brainDir, relPath); err != nil {
|
||||||
|
slog.Warn(op+": graph index failed", "path", relPath, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wingFromRelPath extracts the wing 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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package brainstore_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreWriteReturnsHandle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# X\n\nbody\n", Filename: "x", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/x.md", ref.Path)
|
||||||
|
assert.Equal(t, ref.Path, ref.ID)
|
||||||
|
assert.NotEmpty(t, ref.ContentHash)
|
||||||
|
assert.False(t, ref.Superseded)
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "wiki/a/facts/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateSupersedes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||||
|
Content: "new\n", Wing: "a", Hall: "facts", Reason: "changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, ref.Superseded)
|
||||||
|
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||||
|
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||||
|
assert.Contains(t, string(got), "new")
|
||||||
|
assert.Contains(t, string(got), "supersede_reason: changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateByFullPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{Content: "old\n", Filename: "n", Wing: "a", Hall: "facts"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "wiki/a/facts/n.md", capture.Note{Content: "fresh\n"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/a/facts/n.md", ref.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateMissingErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Update(context.Background(), "ghost", capture.Note{Content: "x\n", Wing: "a", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/a/facts/ghost.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "update must not create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreGetRoundTripsHash(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# Body\n\ntext\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
note, err := s.Get(context.Background(), ref.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ref.ContentHash, note.ContentHash, "write→get hash round-trips")
|
||||||
|
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||||
|
assert.Contains(t, note.Body, "# Body")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreWriteUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "See [[target]] and [[secret-hygiene]].\n",
|
||||||
|
Filename: "src", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"secret-hygiene"}, ref.Unlinked)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/src.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "[[target]]")
|
||||||
|
assert.Contains(t, string(got), "and secret-hygiene.")
|
||||||
|
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||||
|
Content: "Related: [[cad-babysit]].\n", Wing: "a", Hall: "facts", Reason: "r",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"cad-babysit"}, ref.Unlinked)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Related: cad-babysit.")
|
||||||
|
assert.NotContains(t, string(got), "[[cad-babysit]]")
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Package capture is the Clean-Architecture use-case for the uniform
|
||||||
|
// capture capability (issue #49/#51): persist a finished session's
|
||||||
|
// valuable output — insights → brain, action items → Gitea tickets —
|
||||||
|
// with one invocation, identical core behaviour across every harness.
|
||||||
|
//
|
||||||
|
// This package is pure orchestration. It depends only on ports
|
||||||
|
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
||||||
|
// or audit I/O. The real adapters are wired in #52 (Gitea tracker), #53
|
||||||
|
// (REST + I1 origin gate), and #54/#55 (audit path + relay). The I1
|
||||||
|
// sovereignty refusal and the classification-aware audit degradation are
|
||||||
|
// deliberately NOT here — those need the server-derived principal origin
|
||||||
|
// (#53) and the loki/buffer machinery (#54). What lives here is everything
|
||||||
|
// testable against fakes: validation, effective-classification resolution
|
||||||
|
// (stricter wins), best-effort orchestration, and the partial receipt.
|
||||||
|
package capture
|
||||||
|
|
||||||
|
// Zone is the trust zone a capture originates from, server-derived from
|
||||||
|
// the authenticated principal (spec §4.2 / I1). It is NEVER taken from
|
||||||
|
// caller input — context.Harness is descriptive telemetry only.
|
||||||
|
type Zone int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ZoneUnknown means the origin was not set. The REST adapter always
|
||||||
|
// sets a concrete zone; the service treats Unknown as "not gated" (only
|
||||||
|
// an explicit ZoneUSNexus triggers the I1 refusal) so the gate can
|
||||||
|
// never fire on a caller-controllable default.
|
||||||
|
ZoneUnknown Zone = iota
|
||||||
|
// ZoneSovereign is sovereign soil (homelab / Tailscale CLI callers).
|
||||||
|
ZoneSovereign
|
||||||
|
// ZoneUSNexus is a non-sovereign US-jurisdiction surface (e.g.
|
||||||
|
// claude.ai). Confidential captures through it are refused (I1).
|
||||||
|
ZoneUSNexus
|
||||||
|
)
|
||||||
|
|
||||||
|
// String renders the zone for audit/refusal messages.
|
||||||
|
func (z Zone) String() string {
|
||||||
|
switch z {
|
||||||
|
case ZoneSovereign:
|
||||||
|
return "sovereign-soil"
|
||||||
|
case ZoneUSNexus:
|
||||||
|
return "us-nexus"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureContext is the per-session metadata accompanying a capture.
|
||||||
|
//
|
||||||
|
// Classification is the caller-declared sensitivity (model C, spec §4.1):
|
||||||
|
// the server independently derives the target's classification and gates
|
||||||
|
// on the stricter of the two. Principal and Origin are server-derived from
|
||||||
|
// the authenticated identity (the REST adapter populates them); they are
|
||||||
|
// never caller-asserted. Harness is descriptive telemetry only — never a
|
||||||
|
// gate input.
|
||||||
|
type CaptureContext struct {
|
||||||
|
Harness string
|
||||||
|
SessionRef string
|
||||||
|
Fidelity string
|
||||||
|
Actor string
|
||||||
|
Classification string // caller-declared level token ("" = unspecified)
|
||||||
|
Principal string // server-derived (auth); audit identity
|
||||||
|
Origin Zone // server-derived trust zone; the I1 gate input
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insight is one piece of session knowledge bound for the brain. A
|
||||||
|
// non-empty SupersedeSlug routes to Update (revise in place); otherwise
|
||||||
|
// Write (create).
|
||||||
|
type Insight struct {
|
||||||
|
Text string
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
SupersedeSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket is one action item bound for a Gitea repo. Owner is always the
|
||||||
|
// operator (set by the tracker adapter), never carried here.
|
||||||
|
type Ticket struct {
|
||||||
|
Repo string
|
||||||
|
Action string // create | close | comment
|
||||||
|
Number int // required for close/comment
|
||||||
|
Title string // required for create
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureInput is the whole capture request.
|
||||||
|
type CaptureInput struct {
|
||||||
|
Context CaptureContext
|
||||||
|
Insights []Insight
|
||||||
|
Tickets []Ticket
|
||||||
|
DryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsightResult is the per-insight outcome in the receipt.
|
||||||
|
type InsightResult struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
ContentHash string `json:"content_hash,omitempty"`
|
||||||
|
Superseded bool `json:"superseded"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketResult is the per-ticket outcome in the receipt.
|
||||||
|
type TicketResult struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemError pins a failure to a specific request item for the partial
|
||||||
|
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
||||||
|
type ItemError struct {
|
||||||
|
Item string `json:"item"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureReceipt is the structured, partial-aware result. Per-item ok
|
||||||
|
// flags plus a flat Errors list make partial success explicit; the
|
||||||
|
// caller never has to infer what landed.
|
||||||
|
type CaptureReceipt struct {
|
||||||
|
Insights []InsightResult `json:"insights"`
|
||||||
|
Tickets []TicketResult `json:"tickets"`
|
||||||
|
Errors []ItemError `json:"errors"`
|
||||||
|
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
// AuditBuffered is true when the central audit sink was unreachable and
|
||||||
|
// this capture's audit record was written to the durable local buffer
|
||||||
|
// instead (internal/public tier). Surfaces the degraded state to the
|
||||||
|
// caller per §4.4.
|
||||||
|
AuditBuffered bool `json:"audit_buffered,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ref is the read-after-write handle returned by a brain write/update —
|
||||||
|
// the #45 contract. ContentHash lets the caller confirm what landed
|
||||||
|
// without a re-query; for an Update, Superseded is true.
|
||||||
|
type Ref struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
ContentHash string
|
||||||
|
Superseded bool
|
||||||
|
// Unlinked names the [[wikilink]] targets the store unwrapped to plain
|
||||||
|
// text because they resolve to no entry. Reported rather than rejected:
|
||||||
|
// see brain.StripUnresolvedLinks for why the write still lands.
|
||||||
|
Unlinked []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||||
|
// confirmation primitive (a direct fetch, never a semantic query).
|
||||||
|
type StoredNote struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
ContentHash string
|
||||||
|
Frontmatter map[string]string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
||||||
|
// and the legacy type/domain fields so a single BrainStore serves both
|
||||||
|
// capture insights and the existing MCP brain_write surface. Reason is
|
||||||
|
// the supersede rationale, used only by Update.
|
||||||
|
type Note struct {
|
||||||
|
Content string
|
||||||
|
Filename string
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
Type string
|
||||||
|
Domain string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrainStore is the brain persistence port — the shared implementation of
|
||||||
|
// the #45 write/update/get verbs that both the MCP handlers and capture
|
||||||
|
// call, so there is one implementation, not two. The read-after-write +
|
||||||
|
// staleness discipline lives behind this interface so no caller carries
|
||||||
|
// the rule.
|
||||||
|
type BrainStore interface {
|
||||||
|
Write(ctx context.Context, n Note) (Ref, error)
|
||||||
|
Update(ctx context.Context, slug string, n Note) (Ref, error)
|
||||||
|
Get(ctx context.Context, id string) (StoredNote, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueRef identifies a ticket touched by the tracker.
|
||||||
|
type IssueRef struct {
|
||||||
|
Repo string
|
||||||
|
Number int
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTracker is the Gitea ticket port. The implementation (#52) always
|
||||||
|
// scopes to owner "mathias"; the port deliberately omits owner.
|
||||||
|
type IssueTracker interface {
|
||||||
|
CreateIssue(ctx context.Context, repo, title, body string) (IssueRef, error)
|
||||||
|
// CloseIssue closes an issue, optionally posting a closing comment
|
||||||
|
// first (empty comment ⇒ close only).
|
||||||
|
CloseIssue(ctx context.Context, repo string, number int, comment string) (IssueRef, error)
|
||||||
|
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassificationPolicy derives a target's sensitivity (model C). The
|
||||||
|
// "stricter wins" combination of declared vs derived is use-case policy
|
||||||
|
// and lives in the service, so the port stays minimal. Satisfied by
|
||||||
|
// classification.Config (#50).
|
||||||
|
type ClassificationPolicy interface {
|
||||||
|
Derive(target classification.Target) classification.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditEntry is the request-level audit record (I5): who/what captured
|
||||||
|
// what, when, via which principal. SecurityEvents carries anomalies such
|
||||||
|
// as a caller under-declaring sensitivity relative to the target floor.
|
||||||
|
type AuditEntry struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Principal string
|
||||||
|
Actor string
|
||||||
|
Harness string
|
||||||
|
SessionRef string
|
||||||
|
EffectiveClassification string
|
||||||
|
Items []string
|
||||||
|
SecurityEvents []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditOutcome is how a capture's audit record was (or will be) persisted.
|
||||||
|
type AuditOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AuditCentral means the record goes to the central sink (loki).
|
||||||
|
AuditCentral AuditOutcome = iota
|
||||||
|
// AuditBuffered means the central sink was unreachable and the record
|
||||||
|
// is written to a durable local buffer for later reconciliation
|
||||||
|
// (internal/public tier only).
|
||||||
|
AuditBuffered
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditSink is the two-phase, classification-aware audit port (I5, §4.4).
|
||||||
|
//
|
||||||
|
// Reserve runs BEFORE any write and decides whether the capture can be
|
||||||
|
// audited at its effective classification: it returns the outcome to use,
|
||||||
|
// or an error to refuse the capture before anything is written
|
||||||
|
// (confidential + central sink down → refuse; the all-tiers floor when
|
||||||
|
// nothing can record → refuse). Record runs AFTER the writes and persists
|
||||||
|
// the final entry per the reserved outcome.
|
||||||
|
//
|
||||||
|
// Splitting reserve from record is what lets "confidential + sink-down →
|
||||||
|
// refuse before any write" be literally true while the record itself
|
||||||
|
// (which lists what landed) is necessarily written afterwards.
|
||||||
|
type AuditSink interface {
|
||||||
|
Reserve(ctx context.Context, level classification.Level) (AuditOutcome, error)
|
||||||
|
Record(ctx context.Context, e AuditEntry, outcome AuditOutcome) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service is the CaptureSession use-case. It depends only on ports.
|
||||||
|
type Service struct {
|
||||||
|
brain BrainStore
|
||||||
|
issues IssueTracker
|
||||||
|
policy ClassificationPolicy
|
||||||
|
audit AuditSink
|
||||||
|
|
||||||
|
// now is the clock, injectable for deterministic audit timestamps in
|
||||||
|
// tests.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService constructs a Service from its ports.
|
||||||
|
func NewService(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
|
return &Service{brain: b, issues: tr, policy: p, audit: a, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||||
|
|
||||||
|
// ErrSovereigntyRefused is returned when the I1 gate refuses a capture
|
||||||
|
// (confidential effective classification through a us-nexus origin). The
|
||||||
|
// REST adapter maps it to HTTP 403. Callers test with errors.Is.
|
||||||
|
var ErrSovereigntyRefused = fmt.Errorf("capture refused by I1 sovereignty gate")
|
||||||
|
|
||||||
|
// ErrAuditUnavailable is returned when the I5 audit gate refuses a capture
|
||||||
|
// before any write: a confidential capture whose central audit sink is
|
||||||
|
// unreachable, or the all-tiers floor where nothing can record the audit.
|
||||||
|
// The REST adapter maps it to HTTP 503. Callers test with errors.Is.
|
||||||
|
var ErrAuditUnavailable = fmt.Errorf("capture refused: audit substrate unavailable")
|
||||||
|
|
||||||
|
// assertedZoneMismatch returns a security-event string when the caller's
|
||||||
|
// harness label asserts a trust zone that contradicts the server-derived
|
||||||
|
// origin. A harness label that names no zone (the normal case, e.g.
|
||||||
|
// "claude-code") returns "". The label is never used as a gate input —
|
||||||
|
// this only flags the discrepancy for the audit trail.
|
||||||
|
func assertedZoneMismatch(harness string, derived Zone) string {
|
||||||
|
var asserted Zone
|
||||||
|
switch strings.ToLower(strings.TrimSpace(harness)) {
|
||||||
|
case "sovereign-soil", "sovereign":
|
||||||
|
asserted = ZoneSovereign
|
||||||
|
case "us-nexus", "usnexus":
|
||||||
|
asserted = ZoneUSNexus
|
||||||
|
default:
|
||||||
|
return "" // no zone claim
|
||||||
|
}
|
||||||
|
if asserted != derived {
|
||||||
|
return fmt.Sprintf("asserted-vs-derived origin mismatch: harness asserted %s, principal resolves to %s",
|
||||||
|
asserted, derived)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture runs the use-case: validate (fail-closed), resolve effective
|
||||||
|
// classification (stricter of declared vs target-derived), then persist
|
||||||
|
// insights → tickets → summary best-effort, emit an audit record, and
|
||||||
|
// return a partial-aware receipt.
|
||||||
|
//
|
||||||
|
// A validation failure returns a non-nil error with nothing written. A
|
||||||
|
// per-item execution failure is recorded in the receipt (no rollback);
|
||||||
|
// the call still returns a nil error so the caller gets the partial
|
||||||
|
// receipt. The I1 origin gate and audit-down degradation are layered on
|
||||||
|
// by #53/#54 around this core.
|
||||||
|
func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt, error) {
|
||||||
|
if err := s.validate(in); err != nil {
|
||||||
|
return CaptureReceipt{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
declared := classification.Public // unspecified ⇒ lowest ⇒ target floor governs
|
||||||
|
if in.Context.Classification != "" {
|
||||||
|
// Already validated parseable.
|
||||||
|
declared, _ = classification.ParseLevel(in.Context.Classification)
|
||||||
|
}
|
||||||
|
|
||||||
|
effective, securityEvents, escalators := s.resolveClassification(declared, in)
|
||||||
|
|
||||||
|
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
||||||
|
// label that names a different zone is descriptive-only and logged as a
|
||||||
|
// security event (spec §4.2: a control keyed on attacker-suppliable
|
||||||
|
// input is not a control).
|
||||||
|
if ev := assertedZoneMismatch(in.Context.Harness, in.Context.Origin); ev != "" {
|
||||||
|
securityEvents = append(securityEvents, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// I1 sovereignty gate: a confidential capture through a us-nexus origin
|
||||||
|
// is refused before ANY write. The refusal itself is audited (best
|
||||||
|
// effort) — refusals must be reconstructable too.
|
||||||
|
if effective == classification.Confidential && in.Context.Origin == ZoneUSNexus {
|
||||||
|
_ = s.audit.Record(ctx, AuditEntry{
|
||||||
|
Timestamp: s.now().UTC(),
|
||||||
|
Principal: in.Context.Principal,
|
||||||
|
Actor: in.Context.Actor,
|
||||||
|
Harness: in.Context.Harness,
|
||||||
|
SessionRef: in.Context.SessionRef,
|
||||||
|
EffectiveClassification: effective.String(),
|
||||||
|
Items: nil, // refused before any write
|
||||||
|
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
||||||
|
}, AuditCentral)
|
||||||
|
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin (%s)",
|
||||||
|
ErrSovereigntyRefused, in.Context.Origin, escalationReason(escalators))
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt := CaptureReceipt{
|
||||||
|
Errors: []ItemError{},
|
||||||
|
EffectiveClassification: effective.String(),
|
||||||
|
DryRun: in.DryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.DryRun {
|
||||||
|
// Would-be receipt: mark planned items ok, write nothing (not even
|
||||||
|
// audit — dry_run touches nothing).
|
||||||
|
for range in.Insights {
|
||||||
|
receipt.Insights = append(receipt.Insights, InsightResult{OK: true})
|
||||||
|
}
|
||||||
|
for _, tk := range in.Tickets {
|
||||||
|
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// I5 audit gate: decide BEFORE any write whether this capture can be
|
||||||
|
// audited at its effective classification. Confidential + central sink
|
||||||
|
// down → refuse here, before writing anything; the all-tiers floor
|
||||||
|
// (nothing can record) likewise refuses. Internal/public degrade to the
|
||||||
|
// durable local buffer (signalled by AuditBuffered).
|
||||||
|
outcome, err := s.audit.Reserve(ctx, effective)
|
||||||
|
if err != nil {
|
||||||
|
return CaptureReceipt{}, fmt.Errorf("%w: %v", ErrAuditUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var landed []string
|
||||||
|
|
||||||
|
for i, ins := range in.Insights {
|
||||||
|
res, item, err := s.persistInsight(ctx, ins)
|
||||||
|
receipt.Insights = append(receipt.Insights, res)
|
||||||
|
if err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
landed = append(landed, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tk := range in.Tickets {
|
||||||
|
res, err := s.persistTicket(ctx, tk)
|
||||||
|
receipt.Tickets = append(receipt.Tickets, res)
|
||||||
|
if err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("ticket[%d]", i), Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||||
|
}
|
||||||
|
|
||||||
|
// I5: persist the request-level audit record of exactly what landed,
|
||||||
|
// using the outcome reserved before the writes. AuditBuffered surfaces
|
||||||
|
// the degraded (locally-buffered) state on the receipt.
|
||||||
|
if err := s.audit.Record(ctx, AuditEntry{
|
||||||
|
Timestamp: s.now().UTC(),
|
||||||
|
Principal: in.Context.Principal,
|
||||||
|
Actor: in.Context.Actor,
|
||||||
|
Harness: in.Context.Harness,
|
||||||
|
SessionRef: in.Context.SessionRef,
|
||||||
|
EffectiveClassification: effective.String(),
|
||||||
|
Items: landed,
|
||||||
|
SecurityEvents: securityEvents,
|
||||||
|
}, outcome); err != nil {
|
||||||
|
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
||||||
|
}
|
||||||
|
if outcome == AuditBuffered {
|
||||||
|
receipt.AuditBuffered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate enforces fail-closed structural validity over the whole
|
||||||
|
// request before any write. A bad declared classification, an invalid
|
||||||
|
// wing/hall, an empty insight, or a malformed ticket aborts the capture
|
||||||
|
// with nothing written.
|
||||||
|
func (s *Service) validate(in CaptureInput) error {
|
||||||
|
if in.Context.Classification != "" {
|
||||||
|
if _, err := classification.ParseLevel(in.Context.Classification); err != nil {
|
||||||
|
return fmt.Errorf("context.classification: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, ins := range in.Insights {
|
||||||
|
if strings.TrimSpace(ins.Text) == "" {
|
||||||
|
return fmt.Errorf("insight[%d]: text is required", i)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ins.Wing) == "" {
|
||||||
|
return fmt.Errorf("insight[%d]: wing is required", i)
|
||||||
|
}
|
||||||
|
if !brain.IsValidHall(ins.Hall) {
|
||||||
|
return fmt.Errorf("insight[%d]: invalid hall %q", i, ins.Hall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, tk := range in.Tickets {
|
||||||
|
if strings.TrimSpace(tk.Repo) == "" {
|
||||||
|
return fmt.Errorf("ticket[%d]: repo is required", i)
|
||||||
|
}
|
||||||
|
if !validActions[tk.Action] {
|
||||||
|
return fmt.Errorf("ticket[%d]: invalid action %q (want create/close/comment)", i, tk.Action)
|
||||||
|
}
|
||||||
|
if tk.Action == "create" && strings.TrimSpace(tk.Title) == "" {
|
||||||
|
return fmt.Errorf("ticket[%d]: create requires a title", i)
|
||||||
|
}
|
||||||
|
if (tk.Action == "close" || tk.Action == "comment") && tk.Number <= 0 {
|
||||||
|
return fmt.Errorf("ticket[%d]: %s requires an issue number", i, tk.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveClassification computes the effective level (stricter of declared
|
||||||
|
// and every target's derived level), collects a security event whenever the
|
||||||
|
// caller under-declared relative to a target floor, and names the targets
|
||||||
|
// that carry the strictest level.
|
||||||
|
//
|
||||||
|
// That third return is what makes an I1 refusal actionable. A caller sending
|
||||||
|
// twenty tickets gets one refusal; without the offending target it cannot
|
||||||
|
// tell which repo tripped the gate, and infra#265 shows what happens next —
|
||||||
|
// the caller concluded the tickets ARRAY was broken, built a fallback of
|
||||||
|
// twenty individual calls, and filed a bug against the wrong thing.
|
||||||
|
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string, []string) {
|
||||||
|
effective := declared
|
||||||
|
var events []string
|
||||||
|
derivedFor := map[string]classification.Level{}
|
||||||
|
var order []string
|
||||||
|
consider := func(kind classification.TargetKind, name string) {
|
||||||
|
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
||||||
|
effective = classification.Stricter(effective, derived)
|
||||||
|
if _, seen := derivedFor[name]; !seen {
|
||||||
|
derivedFor[name] = derived
|
||||||
|
order = append(order, name)
|
||||||
|
}
|
||||||
|
if declared < derived {
|
||||||
|
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
||||||
|
declared, name, kindString(kind), derived))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ins := range in.Insights {
|
||||||
|
consider(classification.WingTarget, ins.Wing)
|
||||||
|
}
|
||||||
|
for _, tk := range in.Tickets {
|
||||||
|
consider(classification.RepoTarget, tk.Repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only targets that actually reach the effective level are named: at
|
||||||
|
// twenty tickets, listing the innocent ones is noise that hides the one
|
||||||
|
// that matters.
|
||||||
|
var escalators []string
|
||||||
|
for _, name := range order {
|
||||||
|
if derivedFor[name] == effective && effective > declared {
|
||||||
|
escalators = append(escalators, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return effective, events, escalators
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalationReason renders why the effective classification landed where it
|
||||||
|
// did, for an operator reading a refusal. Naming the caller's own declaration
|
||||||
|
// matters as much as naming a target: "you asked for this" and "a repo you
|
||||||
|
// touched required it" call for different fixes.
|
||||||
|
func escalationReason(escalators []string) string {
|
||||||
|
if len(escalators) == 0 {
|
||||||
|
return "declared by the caller; no target required it"
|
||||||
|
}
|
||||||
|
return "required by: " + strings.Join(escalators, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||||
|
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
||||||
|
var ref Ref
|
||||||
|
var err error
|
||||||
|
if ins.SupersedeSlug != "" {
|
||||||
|
note.Reason = "superseded via capture"
|
||||||
|
ref, err = s.brain.Update(ctx, ins.SupersedeSlug, note)
|
||||||
|
} else {
|
||||||
|
ref, err = s.brain.Write(ctx, note)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return InsightResult{OK: false, Superseded: ins.SupersedeSlug != ""}, "", err
|
||||||
|
}
|
||||||
|
return InsightResult{
|
||||||
|
ID: ref.ID, Path: ref.Path, ContentHash: ref.ContentHash,
|
||||||
|
Superseded: ref.Superseded, OK: true,
|
||||||
|
}, "insight:" + ref.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, error) {
|
||||||
|
res := TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number}
|
||||||
|
var ref IssueRef
|
||||||
|
var err error
|
||||||
|
switch tk.Action {
|
||||||
|
case "create":
|
||||||
|
ref, err = s.issues.CreateIssue(ctx, tk.Repo, tk.Title, tk.Body)
|
||||||
|
case "close":
|
||||||
|
ref, err = s.issues.CloseIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||||
|
case "comment":
|
||||||
|
ref, err = s.issues.CommentIssue(ctx, tk.Repo, tk.Number, tk.Body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if ref.Number != 0 {
|
||||||
|
res.Number = ref.Number
|
||||||
|
}
|
||||||
|
res.URL = ref.URL
|
||||||
|
res.OK = true
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindString(k classification.TargetKind) string {
|
||||||
|
if k == classification.RepoTarget {
|
||||||
|
return "repo"
|
||||||
|
}
|
||||||
|
return "wing"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
s = strings.TrimLeft(s, "# ")
|
||||||
|
if len(s) > 60 {
|
||||||
|
s = s[:60]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- fakes ---
|
||||||
|
|
||||||
|
type fakeBrain struct {
|
||||||
|
writes []Note
|
||||||
|
updates []Note
|
||||||
|
gets []string
|
||||||
|
failOn func(Note) error // nil = always succeed
|
||||||
|
hashSeq int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) ref(prefix string, n Note, superseded bool) Ref {
|
||||||
|
f.hashSeq++
|
||||||
|
path := "wiki/" + n.Wing + "/" + n.Hall + "/" + n.Filename + ".md"
|
||||||
|
return Ref{ID: path, Path: path, ContentHash: prefix + string(rune('0'+f.hashSeq)), Superseded: superseded}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) Write(_ context.Context, n Note) (Ref, error) {
|
||||||
|
if f.failOn != nil {
|
||||||
|
if err := f.failOn(n); err != nil {
|
||||||
|
return Ref{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.writes = append(f.writes, n)
|
||||||
|
return f.ref("w", n, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) Update(_ context.Context, slug string, n Note) (Ref, error) {
|
||||||
|
if f.failOn != nil {
|
||||||
|
if err := f.failOn(n); err != nil {
|
||||||
|
return Ref{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n.Filename = slug
|
||||||
|
f.updates = append(f.updates, n)
|
||||||
|
return f.ref("u", n, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBrain) Get(_ context.Context, id string) (StoredNote, error) {
|
||||||
|
f.gets = append(f.gets, id)
|
||||||
|
return StoredNote{ID: id, Path: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTracker struct {
|
||||||
|
created []string
|
||||||
|
closed []int
|
||||||
|
comments []int
|
||||||
|
err error
|
||||||
|
// failOnTitle makes CreateIssue fail for one specific title, so a
|
||||||
|
// partial-failure receipt can be exercised without failing every item.
|
||||||
|
failOnTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return IssueRef{}, f.err
|
||||||
|
}
|
||||||
|
if f.failOnTitle != "" && title == f.failOnTitle {
|
||||||
|
return IssueRef{}, errors.New("tracker refused this one")
|
||||||
|
}
|
||||||
|
f.created = append(f.created, repo+":"+title)
|
||||||
|
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTracker) CloseIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return IssueRef{}, f.err
|
||||||
|
}
|
||||||
|
f.closed = append(f.closed, number)
|
||||||
|
return IssueRef{Repo: repo, Number: number}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _ string) (IssueRef, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return IssueRef{}, f.err
|
||||||
|
}
|
||||||
|
f.comments = append(f.comments, number)
|
||||||
|
return IssueRef{Repo: repo, Number: number}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakePolicy derives from an explicit map; default Internal so tests pin
|
||||||
|
// behaviour without depending on the real defaulting.
|
||||||
|
type fakePolicy struct {
|
||||||
|
tags map[string]classification.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
||||||
|
if lvl, ok := p.tags[t.Name]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return classification.Internal
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAudit struct {
|
||||||
|
entries []AuditEntry
|
||||||
|
err error // Record error
|
||||||
|
reserveErr error // Reserve error (refuse before write)
|
||||||
|
reserveMode AuditOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudit) Reserve(_ context.Context, _ classification.Level) (AuditOutcome, error) {
|
||||||
|
if f.reserveErr != nil {
|
||||||
|
return 0, f.reserveErr
|
||||||
|
}
|
||||||
|
return f.reserveMode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
f.entries = append(f.entries, e)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
|
s := NewService(b, tr, p, a)
|
||||||
|
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseCtx() CaptureContext {
|
||||||
|
return CaptureContext{Harness: "claude-code", Actor: "mathias", Principal: "mathias", Classification: "internal"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scenarios ---
|
||||||
|
|
||||||
|
func TestCaptureHappyPath(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{
|
||||||
|
{Text: "a", Wing: "hyperguild", Hall: "decisions", SupersedeSlug: ""},
|
||||||
|
{Text: "b", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "do x", Body: "y"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Insights, 2)
|
||||||
|
for _, r := range rec.Insights {
|
||||||
|
assert.True(t, r.OK)
|
||||||
|
assert.NotEmpty(t, r.ContentHash, "read-after-write hash returned")
|
||||||
|
}
|
||||||
|
require.Len(t, rec.Tickets, 1)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.Equal(t, 2, len(b.writes))
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
// Audit emitted naming principal/harness + items that landed.
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.Equal(t, "mathias", au.entries[0].Principal)
|
||||||
|
assert.Equal(t, "claude-code", au.entries[0].Harness)
|
||||||
|
assert.Len(t, au.entries[0].Items, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "revised", Wing: "hyperguild", Hall: "facts", SupersedeSlug: "prior-note"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, b.writes, "supersede must not create")
|
||||||
|
require.Len(t, b.updates, 1)
|
||||||
|
assert.Equal(t, "prior-note", b.updates[0].Filename)
|
||||||
|
assert.True(t, rec.Insights[0].Superseded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureValidationFailClosed(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{
|
||||||
|
{Text: "ok", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
{Text: "bad", Wing: "hyperguild", Hall: "garbage-hall"}, // invalid hall
|
||||||
|
},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
// Nothing written anywhere.
|
||||||
|
assert.Empty(t, b.writes)
|
||||||
|
assert.Empty(t, b.updates)
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
assert.Empty(t, au.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "close"}}, // close needs number
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapturePartialFailureBestEffort(t *testing.T) {
|
||||||
|
b := &fakeBrain{failOn: func(n Note) error {
|
||||||
|
if strings.Contains(n.Content, "FAIL") {
|
||||||
|
return errors.New("disk full")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{
|
||||||
|
{Text: "good one", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
{Text: "FAIL here", Wing: "hyperguild", Hall: "facts"},
|
||||||
|
},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "partial failure is not a request-level error")
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.False(t, rec.Insights[1].OK)
|
||||||
|
assert.True(t, rec.Tickets[0].OK, "ticket still persisted; no rollback")
|
||||||
|
require.Len(t, rec.Errors, 1)
|
||||||
|
assert.Equal(t, "insight[1]", rec.Errors[0].Item)
|
||||||
|
// Audit reflects exactly what landed: 1 insight + 1 ticket.
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.Len(t, au.entries[0].Items, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureDryRunWritesNothing(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
DryRun: true,
|
||||||
|
Insights: []Insight{{Text: "a", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "t"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.DryRun)
|
||||||
|
assert.Len(t, rec.Insights, 1)
|
||||||
|
assert.True(t, rec.Insights[0].OK, "would-be receipt marks planned items ok")
|
||||||
|
// Nothing written anywhere, including audit.
|
||||||
|
assert.Empty(t, b.writes)
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
assert.Empty(t, au.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureStricterClassificationWins(t *testing.T) {
|
||||||
|
// Caller declares internal; target wing tagged confidential → effective confidential + security event.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "internal"
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.NotEmpty(t, au.entries[0].SecurityEvents, "under-declaration logged as security event")
|
||||||
|
assert.Equal(t, "confidential", au.entries[0].EffectiveClassification)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
||||||
|
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "confidential", rec.EffectiveClassification)
|
||||||
|
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- I1 sovereignty gate (#53) ---
|
||||||
|
|
||||||
|
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(b, tr, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
ctx.Origin = ZoneUSNexus
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||||
|
// Refused before any write.
|
||||||
|
assert.Empty(t, b.writes)
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
// Refusal is audited.
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
assert.Empty(t, au.entries[0].Items, "no items landed on refusal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
||||||
|
b := &fakeBrain{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{})
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
ctx.Origin = ZoneSovereign
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.Len(t, b.writes, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
||||||
|
// Caller asserts harness "sovereign-soil" but principal resolves to
|
||||||
|
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
||||||
|
au := &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Harness = "sovereign-soil" // asserted
|
||||||
|
ctx.Origin = ZoneUSNexus // server-derived
|
||||||
|
ctx.Classification = "confidential"
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "client-seb", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
joined := strings.Join(au.entries[0].SecurityEvents, " | ")
|
||||||
|
assert.Contains(t, joined, "asserted-vs-derived origin mismatch")
|
||||||
|
assert.Contains(t, joined, "I1 refusal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||||
|
// us-nexus origin is fine for non-confidential data.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- I5 audit gate (#54) ---
|
||||||
|
|
||||||
|
func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
||||||
|
// Reserve refusing (e.g. confidential + central sink down, or the floor)
|
||||||
|
// aborts the capture before any write.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrAuditUnavailable)
|
||||||
|
assert.Empty(t, b.writes, "nothing written when audit unavailable")
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
||||||
|
// Reserve returns AuditBuffered (internal/public, central down) → capture
|
||||||
|
// proceeds and the receipt flags the degraded audit state.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.Insights[0].OK, "capture proceeds on degraded audit")
|
||||||
|
assert.True(t, rec.AuditBuffered, "receipt flags locally-buffered audit")
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||||
|
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||||
|
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
DryRun: true,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "dry-run does not hit the audit gate")
|
||||||
|
assert.True(t, rec.DryRun)
|
||||||
|
assert.Empty(t, au.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- hyperguild#91 part 1 -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The reported symptom was "capture with a tickets array fails" (infra#265:
|
||||||
|
// 20 tickets errored, 5 errored, caller fell back to 20 individual
|
||||||
|
// issue_create calls). The array was never the failing dimension — every
|
||||||
|
// ticket targeted a client repo, and the I1 gate refuses that through a
|
||||||
|
// us-nexus origin at ANY length. The refusal did not say which target
|
||||||
|
// escalated the classification, so it read as a malfunction.
|
||||||
|
|
||||||
|
func TestCapture_TicketsArrayWorksAtLengthOne(t *testing.T) {
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "one", Body: "b"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Tickets, 1)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.Len(t, tr.created, 1)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_TicketsArrayWorksAtLengthMany(t *testing.T) {
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
in := CaptureInput{Context: baseCtx()}
|
||||||
|
for i := range 20 {
|
||||||
|
in.Tickets = append(in.Tickets, Ticket{
|
||||||
|
Repo: "hyperguild", Action: "create", Title: fmt.Sprintf("t%02d", i), Body: "b",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), in)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Tickets, 20, "one receipt entry per ticket")
|
||||||
|
for i, r := range rec.Tickets {
|
||||||
|
assert.True(t, r.OK, "ticket %d failed: %+v", i, rec.Errors)
|
||||||
|
}
|
||||||
|
assert.Len(t, tr.created, 20)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_OneFailingTicketDoesNotSinkTheRest(t *testing.T) {
|
||||||
|
// infra#265 asked explicitly: "if ticket 3 of 5 fails, what happened to
|
||||||
|
// 1 and 2?" They land, and the receipt says so per item.
|
||||||
|
b, au := &fakeBrain{}, &fakeAudit{}
|
||||||
|
tr := &fakeTracker{failOnTitle: "boom"}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "ok-1", Body: "b"},
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "boom", Body: "b"},
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "ok-3", Body: "b"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "a per-item failure is a partial receipt, not a call error")
|
||||||
|
require.Len(t, rec.Tickets, 3)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.False(t, rec.Tickets[1].OK)
|
||||||
|
assert.True(t, rec.Tickets[2].OK)
|
||||||
|
require.Len(t, rec.Errors, 1)
|
||||||
|
assert.Contains(t, rec.Errors[0].Item, "ticket[1]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_I1RefusalNamesWhatEscalatedTheClassification(t *testing.T) {
|
||||||
|
// The actual defect behind infra#265. Without the offending target in
|
||||||
|
// the message, a caller with 20 tickets cannot tell which repo tripped
|
||||||
|
// the gate — and reasonably concludes the array itself is broken.
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"trucker-ecommerce": classification.Confidential}}
|
||||||
|
svc := newSvc(b, tr, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Origin = ZoneUSNexus
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Tickets: []Ticket{
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "fine", Body: "b"},
|
||||||
|
{Repo: "trucker-ecommerce", Action: "create", Title: "client work", Body: "b"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||||
|
assert.Contains(t, err.Error(), "trucker-ecommerce",
|
||||||
|
"the refusal must name the target that escalated the classification")
|
||||||
|
assert.NotContains(t, err.Error(), "hyperguild",
|
||||||
|
"only the offending target, or the message is noise at 20 tickets")
|
||||||
|
assert.Empty(t, tr.created, "refused before any write")
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// Package capturehttp is the REST adapter for the capture use-case: the
|
||||||
|
// POST /capture door (#53). It is deliberately thin — authenticate, derive
|
||||||
|
// the trust-zone origin from the authenticated principal, decode the
|
||||||
|
// request, call capture.Service, map the receipt to an HTTP status. No
|
||||||
|
// business logic lives here; the I1 gate, validation, and orchestration
|
||||||
|
// are all in the use-case.
|
||||||
|
package capturehttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator validates a Bearer JWT and returns its subject. The chassis
|
||||||
|
// *auth.JWTValidator satisfies it (including its nil-receiver "disabled"
|
||||||
|
// behaviour), and tests can substitute a fake without a live JWKS.
|
||||||
|
type Validator interface {
|
||||||
|
Validate(ctx context.Context, rawToken string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves POST /capture.
|
||||||
|
type Handler struct {
|
||||||
|
svc *capture.Service
|
||||||
|
validator Validator // nil ⇒ JWT auth disabled
|
||||||
|
staticToken string // "" ⇒ static auth disabled
|
||||||
|
staticPrincipal string // principal name attributed to static-token callers
|
||||||
|
resolver OriginResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a capture HTTP handler. staticToken callers are
|
||||||
|
// attributed to staticPrincipal (a sovereign homelab identity); JWT
|
||||||
|
// callers are attributed to their token subject.
|
||||||
|
func New(svc *capture.Service, validator Validator, staticToken, staticPrincipal string, resolver OriginResolver) *Handler {
|
||||||
|
if staticPrincipal == "" {
|
||||||
|
staticPrincipal = "local-cli"
|
||||||
|
}
|
||||||
|
return &Handler{
|
||||||
|
svc: svc,
|
||||||
|
validator: validator,
|
||||||
|
staticToken: staticToken,
|
||||||
|
staticPrincipal: staticPrincipal,
|
||||||
|
resolver: resolver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wire types — the POST /capture request body.
|
||||||
|
type request struct {
|
||||||
|
Context contextBody `json:"context"`
|
||||||
|
Insights []insightBody `json:"insights"`
|
||||||
|
Tickets []ticketBody `json:"tickets"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextBody struct {
|
||||||
|
Harness string `json:"harness"`
|
||||||
|
SessionRef string `json:"session_ref"`
|
||||||
|
Fidelity string `json:"fidelity"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Classification string `json:"classification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type insightBody struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Wing string `json:"wing"`
|
||||||
|
Hall string `json:"hall"`
|
||||||
|
SupersedeSlug string `json:"supersede_slug,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ticketBody struct {
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Number int `json:"number,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||||
|
// result to an HTTP status.
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, viaStatic, ok := Authenticate(r, h.staticToken, h.staticPrincipal, h.validator)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := DecodeRequest(body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Principal and origin are server-derived — overwrite anything the
|
||||||
|
// caller may have tried to put in the body.
|
||||||
|
in.Context.Principal = principal
|
||||||
|
in.Context.Origin = h.resolver.Resolve(principal, viaStatic)
|
||||||
|
|
||||||
|
rec, err := h.svc.Capture(r.Context(), in)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, capture.ErrSovereigntyRefused):
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
case errors.Is(err, capture.ErrAuditUnavailable):
|
||||||
|
// I5 refusal: confidential + audit sink down, or the all-tiers floor.
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
// Pre-write validation failure (fail-closed).
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, statusFor(rec), rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate mirrors the chassis Bearer precedence (static token wins,
|
||||||
|
// then Dex JWT) and returns the resolved principal plus whether the static
|
||||||
|
// path was taken — the chassis middleware hides both, and capture (REST or
|
||||||
|
// MCP) needs them to derive the trust-zone origin. ok is false when no
|
||||||
|
// credential matched.
|
||||||
|
func Authenticate(r *http.Request, staticToken, staticPrincipal string, validator Validator) (principal string, viaStatic, ok bool) {
|
||||||
|
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !found || raw == "" {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 {
|
||||||
|
return staticPrincipal, true, true
|
||||||
|
}
|
||||||
|
if validator != nil {
|
||||||
|
if sub, err := validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||||
|
return sub, false, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeRequest parses a capture request body into a CaptureInput. Shared
|
||||||
|
// by the REST adapter and the MCP capture tool so the wire shape has one
|
||||||
|
// definition. Principal and Origin are NOT set here — the caller sets them
|
||||||
|
// from the authenticated identity.
|
||||||
|
func DecodeRequest(data []byte) (capture.CaptureInput, error) {
|
||||||
|
var b request
|
||||||
|
if err := json.Unmarshal(data, &b); err != nil {
|
||||||
|
return capture.CaptureInput{}, err
|
||||||
|
}
|
||||||
|
return b.toInput(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b request) toInput() capture.CaptureInput {
|
||||||
|
in := capture.CaptureInput{
|
||||||
|
Context: capture.CaptureContext{
|
||||||
|
Harness: b.Context.Harness,
|
||||||
|
SessionRef: b.Context.SessionRef,
|
||||||
|
Fidelity: b.Context.Fidelity,
|
||||||
|
Actor: b.Context.Actor,
|
||||||
|
Classification: b.Context.Classification,
|
||||||
|
},
|
||||||
|
DryRun: b.DryRun,
|
||||||
|
}
|
||||||
|
for _, i := range b.Insights {
|
||||||
|
in.Insights = append(in.Insights, capture.Insight{
|
||||||
|
Text: i.Text, Wing: i.Wing, Hall: i.Hall, SupersedeSlug: i.SupersedeSlug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, t := range b.Tickets {
|
||||||
|
in.Tickets = append(in.Tickets, capture.Ticket{
|
||||||
|
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusFor maps a receipt to an HTTP status: 200 all-ok (or dry-run),
|
||||||
|
// 207 partial, 502 everything-failed.
|
||||||
|
func statusFor(rec capture.CaptureReceipt) int {
|
||||||
|
if rec.DryRun {
|
||||||
|
return http.StatusOK
|
||||||
|
}
|
||||||
|
var ok, fail int
|
||||||
|
for _, i := range rec.Insights {
|
||||||
|
count(&ok, &fail, i.OK)
|
||||||
|
}
|
||||||
|
for _, t := range rec.Tickets {
|
||||||
|
count(&ok, &fail, t.OK)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case fail == 0:
|
||||||
|
return http.StatusOK
|
||||||
|
case ok == 0:
|
||||||
|
return http.StatusBadGateway // every persistence attempt failed
|
||||||
|
default:
|
||||||
|
return http.StatusMultiStatus // 207: partial success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func count(ok, fail *int, isOK bool) {
|
||||||
|
if isOK {
|
||||||
|
*ok++
|
||||||
|
} else {
|
||||||
|
*fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package capturehttp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const staticTok = "static-secret"
|
||||||
|
|
||||||
|
// fakeValidator stands in for the chassis JWT validator.
|
||||||
|
type fakeValidator struct {
|
||||||
|
subject string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeValidator) Validate(context.Context, string) (string, error) {
|
||||||
|
return f.subject, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTracker struct{ failCreate bool }
|
||||||
|
|
||||||
|
func (f fakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
|
if f.failCreate {
|
||||||
|
return capture.IssueRef{}, errors.New("gitea down")
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
|
return capture.IssueRef{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker, sovereign []string) *capturehttp.Handler {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := classification.Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(t.TempDir()), tr, cfg, audit.NewSlogSink(nil))
|
||||||
|
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(t *testing.T, h *capturehttp.Handler, authz string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/capture", bytes.NewReader(b))
|
||||||
|
if authz != "" {
|
||||||
|
req.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalReq() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthorizedWithoutToken(t *testing.T) {
|
||||||
|
h := newHandler(t, fakeValidator{err: errors.New("no")}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "", internalReq())
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnauthorizedBadToken(t *testing.T) {
|
||||||
|
h := newHandler(t, fakeValidator{err: errors.New("bad jwt")}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer wrong", internalReq())
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHappyPathStaticToken(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var rec capture.CaptureReceipt
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfidentialViaUSNexusRefused(t *testing.T) {
|
||||||
|
// JWT principal not in the sovereign allowlist ⇒ us-nexus; confidential ⇒ 403.
|
||||||
|
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||||
|
assert.Contains(t, rr.Body.String(), "sovereignty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfidentialViaSovereignJWTAllowed(t *testing.T) {
|
||||||
|
// Same confidential payload, but the principal is allowlisted sovereign ⇒ allowed.
|
||||||
|
h := newHandler(t, fakeValidator{subject: "koala-cli"}, fakeTracker{}, []string{"koala-cli"})
|
||||||
|
rr := do(t, h, "Bearer jwt-token", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticTokenIsSovereignSoConfidentialAllowed(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationRejectedBeforeWrite(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "x", "wing": "hyperguild", "hall": "not-a-hall"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialFailureIs207(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{failCreate: true}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "ok insight", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
"tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "fails"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusMultiStatus, rr.Code)
|
||||||
|
var rec capture.CaptureReceipt
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||||
|
assert.True(t, rec.Insights[0].OK)
|
||||||
|
assert.False(t, rec.Tickets[0].OK)
|
||||||
|
assert.Len(t, rec.Errors, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDryRunWritesNothing(t *testing.T) {
|
||||||
|
h := newHandler(t, nil, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, map[string]any{
|
||||||
|
"context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"},
|
||||||
|
"insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}},
|
||||||
|
"dry_run": true,
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var rec capture.CaptureReceipt
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rec))
|
||||||
|
assert.True(t, rec.DryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallerCannotForgeOrigin(t *testing.T) {
|
||||||
|
// Even if the body tried to assert a sovereign harness, a us-nexus JWT
|
||||||
|
// principal + confidential ⇒ refused. (Origin is server-derived.)
|
||||||
|
h := newHandler(t, fakeValidator{subject: "claudeai-oauth-client"}, fakeTracker{}, nil)
|
||||||
|
rr := do(t, h, "Bearer jwt", map[string]any{
|
||||||
|
"context": map[string]any{"harness": "sovereign-soil", "actor": "mathias", "classification": "confidential"},
|
||||||
|
"insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusingAudit refuses at Reserve (e.g. confidential + loki down, or floor).
|
||||||
|
type refusingAudit struct{}
|
||||||
|
|
||||||
|
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||||
|
return 0, errors.New("central audit sink unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditUnavailableIs503(t *testing.T) {
|
||||||
|
cfg, err := classification.Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, cfg, refusingAudit{})
|
||||||
|
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
|
||||||
|
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
||||||
|
assert.Equal(t, http.StatusServiceUnavailable, rr.Code)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package capturehttp
|
||||||
|
|
||||||
|
import "github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
|
||||||
|
// OriginResolver maps an authenticated principal to its trust zone
|
||||||
|
// (spec §4.2). The mapping is server-side and never reads caller input.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - The static-token path is a homelab CLI caller on sovereign soil →
|
||||||
|
// ZoneSovereign.
|
||||||
|
// - A JWT principal in the sovereign allowlist → ZoneSovereign.
|
||||||
|
// - Any other JWT principal (e.g. claude.ai's OAuth identity, or any
|
||||||
|
// unrecognised subject) → ZoneUSNexus.
|
||||||
|
//
|
||||||
|
// The default is the strict one: an unknown principal is treated as
|
||||||
|
// us-nexus so the I1 gate fails safe (refuses confidential), exactly as
|
||||||
|
// an untagged classification target fails safe to confidential (#50).
|
||||||
|
type OriginResolver struct {
|
||||||
|
sovereign map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOriginResolver builds a resolver whose JWT sovereign principals are
|
||||||
|
// the given subjects. The static-token caller is always sovereign and
|
||||||
|
// need not be listed.
|
||||||
|
func NewOriginResolver(sovereignPrincipals []string) OriginResolver {
|
||||||
|
m := make(map[string]bool, len(sovereignPrincipals))
|
||||||
|
for _, p := range sovereignPrincipals {
|
||||||
|
if p != "" {
|
||||||
|
m[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OriginResolver{sovereign: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the trust zone for a principal. viaStatic is true when
|
||||||
|
// the static-token auth path was taken.
|
||||||
|
func (r OriginResolver) Resolve(principal string, viaStatic bool) capture.Zone {
|
||||||
|
if viaStatic || r.sovereign[principal] {
|
||||||
|
return capture.ZoneSovereign
|
||||||
|
}
|
||||||
|
return capture.ZoneUSNexus
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Package claim validates the claim-property frontmatter that says when and
|
||||||
|
// where a note's claim holds, and who decided that (brain#21).
|
||||||
|
//
|
||||||
|
// Two families were designed together and land separately. This is the
|
||||||
|
// applicability envelope:
|
||||||
|
//
|
||||||
|
// half_life: volatile | seasonal | durable — how fast it decays
|
||||||
|
// applies: local | homelab | universal — how widely it holds
|
||||||
|
// claim_provenance: asserted | inferred — who set the above
|
||||||
|
//
|
||||||
|
// The strength family (confidence, contested) follows once the ingest-time
|
||||||
|
// contradiction detector exists to populate it.
|
||||||
|
//
|
||||||
|
// Why the envelope is validated rather than merely accepted: a value outside
|
||||||
|
// the enum is worse than an absent one. A lint skips what it cannot parse, so
|
||||||
|
// the note reads as classified while nothing acts on it — the field becomes
|
||||||
|
// decoration that suppresses the very review it was added to trigger.
|
||||||
|
//
|
||||||
|
// Why `applies` and not `scope`: `scope:` has been in use since brain#5 for
|
||||||
|
// glossary terms (`homelab-core`, `project:<repo>`) — a disjoint value space
|
||||||
|
// on the same key. The shipped schema keeps the name; this one takes another.
|
||||||
|
package claim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field names, exported so writers and lints agree on the spelling.
|
||||||
|
const (
|
||||||
|
FieldHalfLife = "half_life"
|
||||||
|
FieldApplies = "applies"
|
||||||
|
FieldProvenance = "claim_provenance"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
halfLives = []string{"volatile", "seasonal", "durable"}
|
||||||
|
appliesTo = []string{"local", "homelab", "universal"}
|
||||||
|
provenances = []string{"asserted", "inferred"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateEnvelope checks the envelope fields of one note's frontmatter.
|
||||||
|
//
|
||||||
|
// Absent fields are valid: the envelope is opt-in for new writes, and a
|
||||||
|
// corpus-wide backfill is deliberately out of scope (brain#21) — inferring
|
||||||
|
// 1113 notes' properties in bulk has the same failure mode as the extraction
|
||||||
|
// pass that produced 28 wrong wikilinks out of 67.
|
||||||
|
func ValidateEnvelope(fm map[string]string) error {
|
||||||
|
halfLife, hasHalfLife := present(fm, FieldHalfLife)
|
||||||
|
applies, hasApplies := present(fm, FieldApplies)
|
||||||
|
prov, hasProv := present(fm, FieldProvenance)
|
||||||
|
|
||||||
|
if hasHalfLife {
|
||||||
|
if err := oneOf(FieldHalfLife, halfLife, halfLives); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasApplies {
|
||||||
|
if err := oneOf(FieldApplies, applies, appliesTo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasProv {
|
||||||
|
if err := oneOf(FieldProvenance, prov, provenances); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field and its provenance travel together or not at all. A value
|
||||||
|
// whose author is unknown cannot be trusted differently from one Mathias
|
||||||
|
// asserted, which is how an inferred guess hardens into accepted fact.
|
||||||
|
switch {
|
||||||
|
case (hasHalfLife || hasApplies) && !hasProv:
|
||||||
|
return fmt.Errorf("%s is required when %s or %s is set",
|
||||||
|
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||||
|
case hasProv && !hasHalfLife && !hasApplies:
|
||||||
|
return fmt.Errorf("%s is set but neither %s nor %s is",
|
||||||
|
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// present reports a trimmed field value and whether it was set to anything
|
||||||
|
// non-empty. A key written with an empty value is treated as absent rather
|
||||||
|
// than as an error: that is a caller emitting a placeholder, not asserting.
|
||||||
|
func present(fm map[string]string, key string) (string, bool) {
|
||||||
|
v := strings.TrimSpace(fm[key])
|
||||||
|
return v, v != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneOf(field, value string, allowed []string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == value {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %q is not one of %s", field, value, strings.Join(allowed, ", "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Package claim_test covers the claim-property envelope (brain#21).
|
||||||
|
package claim_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEnvelope(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fm map[string]string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "absent is fine — the fields are opt-in on new writes",
|
||||||
|
fm: map[string]string{"wing": "homelab", "hall": "facts"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete envelope with provenance",
|
||||||
|
fm: map[string]string{
|
||||||
|
"half_life": "durable", "applies": "universal", "claim_provenance": "asserted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inferred provenance is equally valid — it just must be stated",
|
||||||
|
fm: map[string]string{
|
||||||
|
"half_life": "volatile", "applies": "local", "claim_provenance": "inferred",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A value nobody can act on is worse than no value: a lint
|
||||||
|
// silently skips it, so the note looks classified and isn't.
|
||||||
|
name: "unknown half_life value",
|
||||||
|
fm: map[string]string{"half_life": "forever", "claim_provenance": "asserted"},
|
||||||
|
wantErr: `half_life: "forever" is not one of volatile, seasonal, durable`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown applies value",
|
||||||
|
fm: map[string]string{"applies": "galaxy", "claim_provenance": "asserted"},
|
||||||
|
wantErr: `applies: "galaxy" is not one of local, homelab, universal`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// brain#21: a value a model inferred must never be
|
||||||
|
// indistinguishable from one Mathias asserted. Writing the field
|
||||||
|
// without saying who set it is exactly that laundering.
|
||||||
|
name: "envelope field without provenance",
|
||||||
|
fm: map[string]string{"half_life": "durable"},
|
||||||
|
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "applies without provenance",
|
||||||
|
fm: map[string]string{"applies": "homelab"},
|
||||||
|
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown provenance value",
|
||||||
|
fm: map[string]string{"half_life": "durable", "claim_provenance": "probably"},
|
||||||
|
wantErr: `claim_provenance: "probably" is not one of asserted, inferred`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Provenance alone says who set fields that aren't there.
|
||||||
|
name: "provenance without any envelope field",
|
||||||
|
fm: map[string]string{"claim_provenance": "asserted"},
|
||||||
|
wantErr: "claim_provenance is set but neither half_life nor applies is",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The glossary wing has used scope: homelab-core since #5. The
|
||||||
|
// envelope deliberately does NOT claim that key (brain#21).
|
||||||
|
name: "glossary scope is untouched",
|
||||||
|
fm: map[string]string{"scope": "homelab-core", "term": "koala"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := claim.ValidateEnvelope(tt.fm)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Package classification defines the data-sensitivity taxonomy and the
|
||||||
|
// per-wing / per-repo tagging the capture server reads to enforce the I1
|
||||||
|
// sovereignty gate (issue #50, capture spec §4.1).
|
||||||
|
//
|
||||||
|
// The single load-bearing property is fail-safe-to-strictest: a target
|
||||||
|
// with no explicit tag and no known default classifies as Confidential,
|
||||||
|
// never as something more permissive. A missing tag must never silently
|
||||||
|
// downgrade — that would turn the I1 gate into theatre.
|
||||||
|
//
|
||||||
|
// Classification is read from an optional classification.yaml at the
|
||||||
|
// brain root. A central, Flux-reconcilable file is deliberate: it is
|
||||||
|
// auditable in one place (I2/I5), it does not require a live Gitea client
|
||||||
|
// to classify a repo (so this package has no dependency on the gitea
|
||||||
|
// tracker work), and it avoids tagging a wing's _index.md frontmatter —
|
||||||
|
// which BuildWingIndex regenerates and would clobber.
|
||||||
|
package classification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level is a data-sensitivity tier. Higher is stricter, so the "stricter
|
||||||
|
// wins" rule (spec §4.1 model C) is a plain max.
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Public Level = iota
|
||||||
|
Internal
|
||||||
|
Confidential
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the canonical lowercase token for a level.
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case Public:
|
||||||
|
return "public"
|
||||||
|
case Internal:
|
||||||
|
return "internal"
|
||||||
|
case Confidential:
|
||||||
|
return "confidential"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("level(%d)", int(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLevel parses a level token (case-insensitive, surrounding space
|
||||||
|
// tolerated). An unknown token is an error — callers must decide what to
|
||||||
|
// do with bad input rather than have it silently coerced.
|
||||||
|
func ParseLevel(s string) (Level, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "public":
|
||||||
|
return Public, nil
|
||||||
|
case "internal":
|
||||||
|
return Internal, nil
|
||||||
|
case "confidential":
|
||||||
|
return Confidential, nil
|
||||||
|
default:
|
||||||
|
return Confidential, fmt.Errorf("unknown classification level %q (want public/internal/confidential)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stricter returns the more restrictive of two levels.
|
||||||
|
func Stricter(a, b Level) Level {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetKind distinguishes the two kinds of capture destination.
|
||||||
|
type TargetKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
WingTarget TargetKind = iota // a brain wing (insights land here)
|
||||||
|
RepoTarget // a Gitea repo (tickets / summaries land here)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target names a capture destination to classify.
|
||||||
|
type Target struct {
|
||||||
|
Kind TargetKind
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the explicit per-wing / per-repo classification tags read
|
||||||
|
// from classification.yaml. Absent entries fall through to the built-in
|
||||||
|
// defaults in defaultFor. The zero value (no file) is valid and applies
|
||||||
|
// defaults to everything.
|
||||||
|
type Config struct {
|
||||||
|
wings map[string]Level
|
||||||
|
repos map[string]Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawConfig is the on-disk YAML shape: string→string maps, parsed into
|
||||||
|
// validated levels by Load.
|
||||||
|
type rawConfig struct {
|
||||||
|
Wings map[string]string `yaml:"wings"`
|
||||||
|
Repos map[string]string `yaml:"repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads classification.yaml from brainDir. An absent file is not an
|
||||||
|
// error — it yields an empty config where every target classifies by the
|
||||||
|
// built-in defaults. A malformed file, or any unparseable level token in
|
||||||
|
// it, is a hard error: a classification source the server cannot trust
|
||||||
|
// must fail loud, not degrade silently.
|
||||||
|
func Load(brainDir string) (*Config, error) {
|
||||||
|
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read classification.yaml: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw rawConfig
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse classification.yaml: %w", err)
|
||||||
|
}
|
||||||
|
for name, lvl := range raw.Wings {
|
||||||
|
parsed, perr := ParseLevel(lvl)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("wing %q: %w", name, perr)
|
||||||
|
}
|
||||||
|
cfg.wings[normalise(name)] = parsed
|
||||||
|
}
|
||||||
|
for name, lvl := range raw.Repos {
|
||||||
|
parsed, perr := ParseLevel(lvl)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, fmt.Errorf("repo %q: %w", name, perr)
|
||||||
|
}
|
||||||
|
cfg.repos[normalise(name)] = parsed
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive returns the classification for any target — the function the
|
||||||
|
// capture use-case calls per item.
|
||||||
|
func (c *Config) Derive(t Target) Level {
|
||||||
|
if t.Kind == RepoTarget {
|
||||||
|
return c.Repo(t.Name)
|
||||||
|
}
|
||||||
|
return c.Wing(t.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wing classifies a brain wing: an explicit tag wins, else defaults.
|
||||||
|
func (c *Config) Wing(name string) Level {
|
||||||
|
if lvl, ok := c.wings[normalise(name)]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return defaultFor(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo classifies a Gitea repo: an explicit tag wins, else defaults.
|
||||||
|
func (c *Config) Repo(name string) Level {
|
||||||
|
if lvl, ok := c.repos[normalise(name)]; ok {
|
||||||
|
return lvl
|
||||||
|
}
|
||||||
|
return defaultFor(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultFor applies the built-in defaulting rules when a target has no
|
||||||
|
// explicit tag:
|
||||||
|
// - client-* → Confidential (client work is confidential by default)
|
||||||
|
// - hyperguild / homelab → Internal (the operator's own infra)
|
||||||
|
// - everything else → Confidential (fail safe to strictest)
|
||||||
|
func defaultFor(name string) Level {
|
||||||
|
n := normalise(name)
|
||||||
|
if strings.HasPrefix(n, "client-") {
|
||||||
|
return Confidential
|
||||||
|
}
|
||||||
|
switch n {
|
||||||
|
case "hyperguild", "homelab":
|
||||||
|
return Internal
|
||||||
|
default:
|
||||||
|
return Confidential
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalise lowercases and trims a wing/repo name so matching and the
|
||||||
|
// client-* prefix check are case-insensitive.
|
||||||
|
func normalise(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package classification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLevelOrderingAndString(t *testing.T) {
|
||||||
|
assert.True(t, Public < Internal)
|
||||||
|
assert.True(t, Internal < Confidential)
|
||||||
|
assert.Equal(t, "public", Public.String())
|
||||||
|
assert.Equal(t, "internal", Internal.String())
|
||||||
|
assert.Equal(t, "confidential", Confidential.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLevel(t *testing.T) {
|
||||||
|
for s, want := range map[string]Level{
|
||||||
|
"public": Public, "internal": Internal, "confidential": Confidential,
|
||||||
|
"PUBLIC": Public, " Confidential ": Confidential,
|
||||||
|
} {
|
||||||
|
got, err := ParseLevel(s)
|
||||||
|
require.NoError(t, err, s)
|
||||||
|
assert.Equal(t, want, got, s)
|
||||||
|
}
|
||||||
|
_, err := ParseLevel("secret")
|
||||||
|
require.Error(t, err, "unknown level must error, not silently default")
|
||||||
|
_, err = ParseLevel("")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStricterReturnsMax(t *testing.T) {
|
||||||
|
assert.Equal(t, Confidential, Stricter(Internal, Confidential))
|
||||||
|
assert.Equal(t, Confidential, Stricter(Confidential, Public))
|
||||||
|
assert.Equal(t, Internal, Stricter(Public, Internal))
|
||||||
|
assert.Equal(t, Public, Stricter(Public, Public))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAbsentFileIsDefaultsOnly(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err, "absent classification.yaml must not be an error — defaults apply")
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
// Pure defaulting still works.
|
||||||
|
assert.Equal(t, Internal, cfg.Wing("hyperguild"))
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("anything-unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadParsesExplicitTags(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"), []byte(
|
||||||
|
"wings:\n research-public: public\n hyperguild: confidential\nrepos:\n infra: internal\n research-public: public\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
cfg, err := Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Explicit tag wins over the built-in default (hyperguild default is internal).
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("hyperguild"))
|
||||||
|
// Explicit public is honoured.
|
||||||
|
assert.Equal(t, Public, cfg.Wing("research-public"))
|
||||||
|
assert.Equal(t, Internal, cfg.Repo("infra"))
|
||||||
|
assert.Equal(t, Public, cfg.Repo("research-public"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsUnknownLevelInFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "classification.yaml"),
|
||||||
|
[]byte("wings:\n x: top-secret\n"), 0o644))
|
||||||
|
_, err := Load(dir)
|
||||||
|
require.Error(t, err, "an unparseable level in the config must fail loud, not be ignored")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWingDefaulting(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
cases := map[string]Level{
|
||||||
|
"client-seb": Confidential, // client-* → confidential
|
||||||
|
"client-mastercard": Confidential,
|
||||||
|
"hyperguild": Internal,
|
||||||
|
"homelab": Internal,
|
||||||
|
"jepa-fx": Confidential, // unknown → fail safe to strictest
|
||||||
|
"": Confidential, // empty → fail safe
|
||||||
|
}
|
||||||
|
for wing, want := range cases {
|
||||||
|
assert.Equal(t, want, cfg.Wing(wing), "wing %q", wing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoDefaulting(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Confidential, cfg.Repo("client-seb-pipeline"))
|
||||||
|
assert.Equal(t, Internal, cfg.Repo("hyperguild"))
|
||||||
|
assert.Equal(t, Confidential, cfg.Repo("some-unknown-repo"), "untagged repo → confidential (fail safe)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveUnifiedTarget(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Internal, cfg.Derive(Target{Kind: WingTarget, Name: "homelab"}))
|
||||||
|
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: RepoTarget, Name: "client-x"}))
|
||||||
|
assert.Equal(t, Confidential, cfg.Derive(Target{Kind: WingTarget, Name: "untagged"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaseInsensitiveMatching(t *testing.T) {
|
||||||
|
cfg, err := Load(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, Confidential, cfg.Wing("Client-SEB"), "client- prefix match is case-insensitive")
|
||||||
|
assert.Equal(t, Internal, cfg.Wing("HyperGuild"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CursorStore tracks how far the watcher has ingested into each
|
||||||
|
// session JSONL file. Keyed by (host, file_path) so the same `~/.claude`
|
||||||
|
// path on different hosts doesn't collide and resumability survives
|
||||||
|
// pod restarts. Idempotent Init lives alongside the rest of the
|
||||||
|
// claudewatcher schema; no separate migration framework.
|
||||||
|
type CursorStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCursorStore opens a pool against dsn. Caller closes the store.
|
||||||
|
func NewCursorStore(ctx context.Context, dsn string) (*CursorStore, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgxpool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping: %w", err)
|
||||||
|
}
|
||||||
|
return &CursorStore{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCursorStoreFromPool wraps an existing pool (so the watcher can
|
||||||
|
// share the brain DSN pool with vectorstore/graphstore without a
|
||||||
|
// second connection set). Caller must NOT close the wrapped pool via
|
||||||
|
// the store — close the pool directly.
|
||||||
|
func NewCursorStoreFromPool(pool *pgxpool.Pool) *CursorStore {
|
||||||
|
return &CursorStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying connection pool when this store owns
|
||||||
|
// it. No-op when the pool was injected via NewCursorStoreFromPool —
|
||||||
|
// pgxpool.Close is idempotent so we lean on that.
|
||||||
|
func (s *CursorStore) Close() {
|
||||||
|
if s.pool != nil {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init creates the claude_session_cursors table when missing.
|
||||||
|
func (s *CursorStore) Init(ctx context.Context) error {
|
||||||
|
const ddl = `
|
||||||
|
CREATE TABLE IF NOT EXISTS claude_session_cursors (
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
byte_offset BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (host, file_path)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS claude_session_cursors_host_idx
|
||||||
|
ON claude_session_cursors (host);
|
||||||
|
`
|
||||||
|
_, err := s.pool.Exec(ctx, ddl)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOffset returns the last recorded byte offset for (host, filePath).
|
||||||
|
// Missing rows are reported as offset=0, ok=false so the caller can
|
||||||
|
// distinguish "never ingested" from "ingested at the start of the
|
||||||
|
// file" (both produce identical behaviour but the metric is useful).
|
||||||
|
func (s *CursorStore) GetOffset(ctx context.Context, host, filePath string) (int64, bool, error) {
|
||||||
|
if host == "" || filePath == "" {
|
||||||
|
return 0, false, errors.New("host and file_path are required")
|
||||||
|
}
|
||||||
|
var offset int64
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT byte_offset FROM claude_session_cursors WHERE host = $1 AND file_path = $2
|
||||||
|
`, host, filePath).Scan(&offset)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, fmt.Errorf("query: %w", err)
|
||||||
|
}
|
||||||
|
return offset, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOffset writes the new offset for (host, filePath). Used after
|
||||||
|
// every successful parse + ingest batch so a crash mid-file rewinds
|
||||||
|
// only to the last committed checkpoint.
|
||||||
|
func (s *CursorStore) SetOffset(ctx context.Context, host, filePath string, offset int64) error {
|
||||||
|
if host == "" || filePath == "" {
|
||||||
|
return errors.New("host and file_path are required")
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
return errors.New("offset must be >= 0")
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO claude_session_cursors (host, file_path, byte_offset, last_seen_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (host, file_path) DO UPDATE
|
||||||
|
SET byte_offset = EXCLUDED.byte_offset,
|
||||||
|
last_seen_at = now()
|
||||||
|
`, host, filePath, offset)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert offset: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
// Package claudewatcher ingests Claude Code session transcripts
|
||||||
|
// (`~/.claude/projects/*/<uuid>.jsonl`) into the brain corpus.
|
||||||
|
//
|
||||||
|
// Schema (observed 2026-05-25 across ~30 session files on koala):
|
||||||
|
//
|
||||||
|
// type=user — user prompts + tool results
|
||||||
|
// type=assistant — model turns; tool_use blocks live in message.content
|
||||||
|
// type=attachment — hook outputs, ingested files
|
||||||
|
// type=system — turn-boundary metadata
|
||||||
|
// type=file-history-snapshot — git-style snapshot of edited files
|
||||||
|
// type=queue-operation, last-prompt, permission-mode, ai-title,
|
||||||
|
// bridge-session — internal bookkeeping, ignored
|
||||||
|
//
|
||||||
|
// The parser is intentionally tolerant: malformed lines are skipped
|
||||||
|
// (caller logs and advances), missing optional fields default to "",
|
||||||
|
// and unknown `type` values are returned as Turn entries with
|
||||||
|
// `Skip=true` so callers can filter cheaply.
|
||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Turn is one parsed JSONL entry from a Claude Code session log.
|
||||||
|
//
|
||||||
|
// Skip is true for entry types we never want to ingest (queue
|
||||||
|
// bookkeeping, snapshots, etc.). Callers fast-path these without
|
||||||
|
// running the scrubber or classifier.
|
||||||
|
type Turn struct {
|
||||||
|
SessionID string
|
||||||
|
Type string
|
||||||
|
ParentUUID string
|
||||||
|
Timestamp time.Time
|
||||||
|
Cwd string
|
||||||
|
GitBranch string
|
||||||
|
Content string // plain-text projection of the entry, ready for the scrubber/classifier
|
||||||
|
ToolName string // populated when an assistant turn invokes a tool
|
||||||
|
OffsetAfter int64 // byte offset in the file just past this entry
|
||||||
|
Skip bool
|
||||||
|
ParseWarning string // non-empty when the entry parsed but had a sub-field we couldn't normalise
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseStream reads JSONL lines from r starting at startOffset and
|
||||||
|
// invokes emit for each parsed entry. emit may return ErrStop to
|
||||||
|
// terminate the scan cleanly. Other emit errors propagate.
|
||||||
|
//
|
||||||
|
// startOffset is informational — the caller is expected to have already
|
||||||
|
// seeked the underlying reader to that offset. ParseStream adds the
|
||||||
|
// number of bytes consumed per line to it to compute Turn.OffsetAfter.
|
||||||
|
//
|
||||||
|
// Lines that fail to unmarshal are logged via warnf and skipped; they
|
||||||
|
// do NOT advance OffsetAfter past the malformed line by themselves,
|
||||||
|
// but the next valid line resumes correctly because bufio.Scanner
|
||||||
|
// preserves stream position.
|
||||||
|
func ParseStream(
|
||||||
|
r io.Reader,
|
||||||
|
startOffset int64,
|
||||||
|
warnf func(format string, args ...any),
|
||||||
|
emit func(Turn) error,
|
||||||
|
) (int64, error) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // some lines are big (tool outputs)
|
||||||
|
|
||||||
|
offset := startOffset
|
||||||
|
for scanner.Scan() {
|
||||||
|
raw := scanner.Bytes()
|
||||||
|
lineLen := int64(len(raw)) + 1 // +1 for the newline
|
||||||
|
t, err := parseTurn(raw)
|
||||||
|
if err != nil {
|
||||||
|
if warnf != nil {
|
||||||
|
warnf("parse: %v (%d bytes)", err, len(raw))
|
||||||
|
}
|
||||||
|
offset += lineLen
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.OffsetAfter = offset + lineLen
|
||||||
|
if err := emit(t); err != nil {
|
||||||
|
if errors.Is(err, ErrStop) {
|
||||||
|
return t.OffsetAfter, nil
|
||||||
|
}
|
||||||
|
return offset, fmt.Errorf("emit: %w", err)
|
||||||
|
}
|
||||||
|
offset = t.OffsetAfter
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return offset, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
return offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrStop terminates a ParseStream loop without surfacing an error.
|
||||||
|
var ErrStop = errors.New("claudewatcher: stop")
|
||||||
|
|
||||||
|
// rawEntry is a permissive shape that covers every type observed in
|
||||||
|
// the JSONL files. Fields we don't care about are intentionally
|
||||||
|
// omitted to keep the unmarshal cheap.
|
||||||
|
type rawEntry struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
ParentUUID string `json:"parentUuid"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
GitBranch string `json:"gitBranch"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
Attachment json.RawMessage `json:"attachment"`
|
||||||
|
Content string `json:"content"` // queue-operation
|
||||||
|
LastPrompt string `json:"lastPrompt"` // last-prompt
|
||||||
|
Subtype string `json:"subtype"` // system
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipTypes lists every entry type we want to never ingest. Marked Skip
|
||||||
|
// at parse time so the caller's filter is a single boolean check.
|
||||||
|
var skipTypes = map[string]struct{}{
|
||||||
|
"queue-operation": {},
|
||||||
|
"last-prompt": {},
|
||||||
|
"permission-mode": {},
|
||||||
|
"ai-title": {},
|
||||||
|
"bridge-session": {},
|
||||||
|
"file-history-snapshot": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTurn(raw []byte) (Turn, error) {
|
||||||
|
var e rawEntry
|
||||||
|
if err := json.Unmarshal(raw, &e); err != nil {
|
||||||
|
return Turn{}, fmt.Errorf("unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
t := Turn{
|
||||||
|
Type: e.Type,
|
||||||
|
SessionID: e.SessionID,
|
||||||
|
ParentUUID: e.ParentUUID,
|
||||||
|
Cwd: e.Cwd,
|
||||||
|
GitBranch: e.GitBranch,
|
||||||
|
}
|
||||||
|
if _, skip := skipTypes[e.Type]; skip {
|
||||||
|
t.Skip = true
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if e.Timestamp != "" {
|
||||||
|
if ts, err := time.Parse(time.RFC3339Nano, e.Timestamp); err == nil {
|
||||||
|
t.Timestamp = ts
|
||||||
|
} else {
|
||||||
|
t.ParseWarning = "timestamp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e.Type {
|
||||||
|
case "user":
|
||||||
|
t.Content = extractMessageText(e.Message)
|
||||||
|
case "assistant":
|
||||||
|
t.Content, t.ToolName = extractAssistantTurn(e.Message)
|
||||||
|
case "attachment":
|
||||||
|
t.Content = extractAttachmentText(e.Attachment)
|
||||||
|
case "system":
|
||||||
|
t.Content = "[system " + e.Subtype + "]"
|
||||||
|
default:
|
||||||
|
// Unknown type — keep the row but mark Skip so callers ignore.
|
||||||
|
t.Skip = true
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMessageText pulls the textual projection out of a user/assistant
|
||||||
|
// message field. The shape is the Anthropic Messages API content-block
|
||||||
|
// array (an array of {type, text|tool_use|tool_result, ...}). We
|
||||||
|
// concatenate every text-bearing block and ignore the rest.
|
||||||
|
func extractMessageText(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var msg struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
Stop string `json:"stop_reason"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Usage map[string]any `json:"usage"`
|
||||||
|
Meta map[string]string `json:"meta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
// Some user turns have message as plain string.
|
||||||
|
var s string
|
||||||
|
if err2 := json.Unmarshal(raw, &s); err2 == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Content can be a string OR an array.
|
||||||
|
var asString string
|
||||||
|
if err := json.Unmarshal(msg.Content, &asString); err == nil {
|
||||||
|
return asString
|
||||||
|
}
|
||||||
|
var blocks []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, b := range blocks {
|
||||||
|
switch b.Type {
|
||||||
|
case "text":
|
||||||
|
sb.WriteString(b.Text)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
case "tool_result":
|
||||||
|
// Tool result content may itself be a string or array of blocks.
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(b.Content, &s); err == nil {
|
||||||
|
sb.WriteString("[tool_result] ")
|
||||||
|
sb.WriteString(s)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var sub []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b.Content, &sub); err == nil {
|
||||||
|
for _, s := range sub {
|
||||||
|
if s.Type == "text" {
|
||||||
|
sb.WriteString("[tool_result] ")
|
||||||
|
sb.WriteString(s.Text)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(sb.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAssistantTurn pulls text + the first tool name (if any) from
|
||||||
|
// an assistant content-block array. Multi-tool turns lose the second
|
||||||
|
// name; the goal is signal for classification, not perfect fidelity.
|
||||||
|
func extractAssistantTurn(raw json.RawMessage) (string, string) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
var msg struct {
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
var blocks []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tool json.RawMessage `json:"input"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
var firstTool string
|
||||||
|
for _, b := range blocks {
|
||||||
|
switch b.Type {
|
||||||
|
case "text":
|
||||||
|
sb.WriteString(b.Text)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
case "tool_use":
|
||||||
|
if firstTool == "" {
|
||||||
|
firstTool = b.Name
|
||||||
|
}
|
||||||
|
sb.WriteString("[tool_use:")
|
||||||
|
sb.WriteString(b.Name)
|
||||||
|
sb.WriteString("]\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(sb.String(), "\n"), firstTool
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAttachmentText pulls text content from an attachment payload,
|
||||||
|
// or returns a short tag when the attachment is a hook event.
|
||||||
|
func extractAttachmentText(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var a struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
HookName string `json:"hookName"`
|
||||||
|
HookEvent string `json:"hookEvent"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &a); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if a.Content != "" {
|
||||||
|
return a.Content
|
||||||
|
}
|
||||||
|
if a.Text != "" {
|
||||||
|
return a.Text
|
||||||
|
}
|
||||||
|
if a.HookName != "" {
|
||||||
|
return "[hook " + a.HookEvent + ":" + a.HookName + "]"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collect(t *testing.T, body string) ([]Turn, int64, error) {
|
||||||
|
t.Helper()
|
||||||
|
var out []Turn
|
||||||
|
end, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
|
||||||
|
out = append(out, tr)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, end, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_UserTurnStringContent(t *testing.T) {
|
||||||
|
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":"hello world"}
|
||||||
|
`
|
||||||
|
turns, end, err := collect(t, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 1)
|
||||||
|
assert.Equal(t, "user", turns[0].Type)
|
||||||
|
assert.Equal(t, "S", turns[0].SessionID)
|
||||||
|
assert.Equal(t, "hello world", turns[0].Content)
|
||||||
|
assert.False(t, turns[0].Skip)
|
||||||
|
assert.Equal(t, int64(len(body)), end)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_UserTurnContentBlocks(t *testing.T) {
|
||||||
|
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"role":"user","content":[{"type":"text","text":"line 1"},{"type":"text","text":"line 2"}]}}
|
||||||
|
`
|
||||||
|
turns, _, err := collect(t, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 1)
|
||||||
|
assert.Equal(t, "line 1\nline 2", turns[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_AssistantToolUse(t *testing.T) {
|
||||||
|
body := `{"type":"assistant","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"content":[{"type":"text","text":"calling now"},{"type":"tool_use","name":"Edit","input":{}}]}}
|
||||||
|
`
|
||||||
|
turns, _, err := collect(t, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 1)
|
||||||
|
assert.Equal(t, "Edit", turns[0].ToolName)
|
||||||
|
assert.Contains(t, turns[0].Content, "calling now")
|
||||||
|
assert.Contains(t, turns[0].Content, "[tool_use:Edit]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_AssistantToolResult(t *testing.T) {
|
||||||
|
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"content":[{"type":"tool_result","content":"output of cmd"}]}}
|
||||||
|
`
|
||||||
|
turns, _, err := collect(t, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 1)
|
||||||
|
assert.Contains(t, turns[0].Content, "[tool_result] output of cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_SkipsBookkeepingTypes(t *testing.T) {
|
||||||
|
body := strings.Join([]string{
|
||||||
|
`{"type":"queue-operation","sessionId":"S","content":"x"}`,
|
||||||
|
`{"type":"last-prompt","sessionId":"S","lastPrompt":"y"}`,
|
||||||
|
`{"type":"permission-mode","sessionId":"S","permissionMode":"auto"}`,
|
||||||
|
`{"type":"ai-title","sessionId":"S","aiTitle":"My session"}`,
|
||||||
|
`{"type":"file-history-snapshot","messageId":"abc"}`,
|
||||||
|
}, "\n") + "\n"
|
||||||
|
turns, _, err := collect(t, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 5)
|
||||||
|
for _, tr := range turns {
|
||||||
|
assert.True(t, tr.Skip, "expected Skip=true for %q", tr.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_UnknownTypeIsSkip(t *testing.T) {
|
||||||
|
body := `{"type":"future-thing","sessionId":"S"}` + "\n"
|
||||||
|
turns, _, err := collect(t, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 1)
|
||||||
|
assert.True(t, turns[0].Skip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_MalformedLineIsSkippedNotFatal(t *testing.T) {
|
||||||
|
body := strings.Join([]string{
|
||||||
|
`{"type":"user","sessionId":"S","message":"first"}`,
|
||||||
|
`{not valid json`,
|
||||||
|
`{"type":"user","sessionId":"S","message":"third"}`,
|
||||||
|
}, "\n") + "\n"
|
||||||
|
var warnings int
|
||||||
|
var turns []Turn
|
||||||
|
_, err := ParseStream(strings.NewReader(body), 0, func(format string, args ...any) {
|
||||||
|
warnings++
|
||||||
|
}, func(tr Turn) error {
|
||||||
|
turns = append(turns, tr)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 2, "first + third should make it through")
|
||||||
|
assert.Equal(t, 1, warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_EmitErrStopHaltsCleanly(t *testing.T) {
|
||||||
|
body := strings.Join([]string{
|
||||||
|
`{"type":"user","sessionId":"S","message":"a"}`,
|
||||||
|
`{"type":"user","sessionId":"S","message":"b"}`,
|
||||||
|
`{"type":"user","sessionId":"S","message":"c"}`,
|
||||||
|
}, "\n") + "\n"
|
||||||
|
count := 0
|
||||||
|
end, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
|
||||||
|
count++
|
||||||
|
if count == 2 {
|
||||||
|
return ErrStop
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, count)
|
||||||
|
assert.Greater(t, end, int64(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_EmitOtherErrorPropagates(t *testing.T) {
|
||||||
|
body := `{"type":"user","sessionId":"S","message":"a"}` + "\n"
|
||||||
|
want := errors.New("boom")
|
||||||
|
_, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
|
||||||
|
return want
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_AttachmentHookEvent(t *testing.T) {
|
||||||
|
body := `{"type":"attachment","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","content":"hook body"}}
|
||||||
|
`
|
||||||
|
turns, _, err := collect(t, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, turns, 1)
|
||||||
|
assert.Equal(t, "hook body", turns[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStream_OffsetAdvances(t *testing.T) {
|
||||||
|
body := `{"type":"user","sessionId":"S","message":"a"}` + "\n" +
|
||||||
|
`{"type":"user","sessionId":"S","message":"b"}` + "\n"
|
||||||
|
var offsets []int64
|
||||||
|
_, err := ParseStream(strings.NewReader(body), 100, nil, func(tr Turn) error {
|
||||||
|
offsets = append(offsets, tr.OffsetAfter)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, offsets, 2)
|
||||||
|
assert.Greater(t, offsets[0], int64(100))
|
||||||
|
assert.Greater(t, offsets[1], offsets[0])
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scrubber drops any turn whose content matches a known-bad pattern.
|
||||||
|
// Fail-closed by design: we'd rather lose signal than ingest credentials
|
||||||
|
// into a public-readable brain. The caller logs the drop reason.
|
||||||
|
//
|
||||||
|
// Rules cover the credential shapes most common to leak through Claude
|
||||||
|
// Code sessions: bearer tokens, postgres URIs with embedded auth, OAuth
|
||||||
|
// secret values, SOPS-encrypted secret blobs (we don't want the
|
||||||
|
// ciphertext either — it's a marker that the original message contained
|
||||||
|
// secret state), PEM-encoded private keys, and the explicit env-var
|
||||||
|
// naming conventions used in the homelab.
|
||||||
|
//
|
||||||
|
// Pattern philosophy: match by shape, not by content. A 40-char hex
|
||||||
|
// string in isolation is fine; the same string after `Authorization:
|
||||||
|
// Bearer ` is not. Tuned to catch known leak vectors from prior
|
||||||
|
// secret-hygiene incidents (POSTGRES_PASSWORD via kubectl exec env,
|
||||||
|
// INFRA_MCP_TOKEN via sops -d output) without dropping every Edit on a
|
||||||
|
// config file.
|
||||||
|
|
||||||
|
// Rule is a single named regex with a redact hint shown in the warn log.
|
||||||
|
type Rule struct {
|
||||||
|
Name string
|
||||||
|
RE *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRules is the regex set applied by Scrub. Mutable for tests but
|
||||||
|
// callers should treat it as read-only at runtime.
|
||||||
|
var DefaultRules = []Rule{
|
||||||
|
// authorization-header is checked before the bare bearer rule so
|
||||||
|
// contextual hits ("Authorization: Bearer X") report the more
|
||||||
|
// specific match name in logs.
|
||||||
|
{Name: "authorization-header", RE: regexp.MustCompile(`(?i)Authorization\s*:\s*[A-Za-z]+\s+\S{8,}`)},
|
||||||
|
{Name: "bearer-token", RE: regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-]{16,}`)},
|
||||||
|
// JWT (header.payload.sig), e.g. a Dex/OAuth token dumped to stdout
|
||||||
|
// without a "Bearer " prefix. Both header and payload base64url-encode
|
||||||
|
// JSON, so both segments begin with "eyJ".
|
||||||
|
{Name: "jwt", RE: regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)},
|
||||||
|
{Name: "postgres-uri-with-password", RE: regexp.MustCompile(`postgres(?:ql)?://[^:\s/]+:[^@\s/]+@`)},
|
||||||
|
{Name: "private-key", RE: regexp.MustCompile(`-----BEGIN[^-]*PRIVATE KEY-----`)},
|
||||||
|
{Name: "ssh-key", RE: regexp.MustCompile(`ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}`)},
|
||||||
|
{Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)},
|
||||||
|
// 1Password service-account token (ops_<base64url>). Long, high-value root
|
||||||
|
// credential; guard the bare value (the _TOKEN= form also hits homelab-env-token).
|
||||||
|
{Name: "op-service-account", RE: regexp.MustCompile(`\bops_[A-Za-z0-9_\-]{40,}`)},
|
||||||
|
// No leading \b: a shell mangle can glue the key to a preceding word
|
||||||
|
// ("yes"+"sk-...") which has no word boundary, and that exact case
|
||||||
|
// leaked a LiteLLM master key past this rule (2026-06-11). Match the
|
||||||
|
// sk- shape wherever it appears; the {32,} length floor keeps short
|
||||||
|
// "task-"/"disk-" words from tripping it.
|
||||||
|
{Name: "openai-sk", RE: regexp.MustCompile(`sk-(?:proj-)?[A-Za-z0-9]{32,}`)},
|
||||||
|
{Name: "anthropic-sk", RE: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_\-]{32,}\b`)},
|
||||||
|
{Name: "aws-access-key", RE: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)},
|
||||||
|
{Name: "homelab-env-token", RE: regexp.MustCompile(`(?i)(?:_TOKEN|_PASSWORD|_API_KEY|_SECRET)\s*[:=]\s*['"]?[A-Za-z0-9._/+\-]{12,}`)},
|
||||||
|
{Name: "sops-encrypted-marker", RE: regexp.MustCompile(`ENC\[AES256_GCM,data:[A-Za-z0-9+/=]{8,}`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraRules is appended to DefaultRules at process startup via
|
||||||
|
// RegisterRule. The mutex guards concurrent RegisterRule calls (rare)
|
||||||
|
// against concurrent Scrub reads (hot path). Scrub takes a read lock
|
||||||
|
// only when extraRules is non-empty, so steady-state cost is zero
|
||||||
|
// when no client-name guard is configured.
|
||||||
|
var (
|
||||||
|
extraRulesMu sync.RWMutex
|
||||||
|
extraRules []Rule
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRule appends a runtime-configured regex to the scrubber's
|
||||||
|
// rule set. Used by main to inject client-name guards from
|
||||||
|
// CLAUDE_INGEST_CLIENT_BLOCK env var (or equivalent SOPS-encrypted
|
||||||
|
// secret) without baking client identities into source code.
|
||||||
|
//
|
||||||
|
// pattern is compiled as-is — callers wrap with `\b...\b` and case
|
||||||
|
// flags as needed. Duplicate names are accepted (rules are positional);
|
||||||
|
// the second registration just fires after the first.
|
||||||
|
func RegisterRule(name, pattern string) error {
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compile rule %q: %w", name, err)
|
||||||
|
}
|
||||||
|
extraRulesMu.Lock()
|
||||||
|
extraRules = append(extraRules, Rule{Name: name, RE: re})
|
||||||
|
extraRulesMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetExtraRules clears every RegisterRule-added rule. Test-only.
|
||||||
|
func ResetExtraRules() {
|
||||||
|
extraRulesMu.Lock()
|
||||||
|
extraRules = nil
|
||||||
|
extraRulesMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub reports the first matching rule, or empty when content is clean.
|
||||||
|
// Empty string is treated as clean. Caller decides what to do on a hit;
|
||||||
|
// the convention in claudewatcher is to drop the turn entirely and emit
|
||||||
|
// a slog.Warn naming the rule.
|
||||||
|
//
|
||||||
|
// Rule order: DefaultRules first (credential shapes), then runtime
|
||||||
|
// RegisterRule additions (client-name guards). Credential leaks
|
||||||
|
// outrank client-name hits in the log because they're strictly more
|
||||||
|
// dangerous.
|
||||||
|
func Scrub(content string) string {
|
||||||
|
if content == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range DefaultRules {
|
||||||
|
if r.RE.MatchString(content) {
|
||||||
|
return r.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraRulesMu.RLock()
|
||||||
|
defer extraRulesMu.RUnlock()
|
||||||
|
for _, r := range extraRules {
|
||||||
|
if r.RE.MatchString(content) {
|
||||||
|
return r.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScrub_PoisonedFixtures(t *testing.T) {
|
||||||
|
// One representative bad-string per rule. If a rule fires for the
|
||||||
|
// wrong content shape later, this table localises the regression.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"bearer-token", "curl -H 'Authorization: Bearer abcdef1234567890ghijklmnop'", "authorization-header"},
|
||||||
|
{"bearer-no-header", "header = Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig", "bearer-token"},
|
||||||
|
{"postgres-uri", "DATABASE_URL=postgres://user:s3cret@10.0.1.20:5432/brain", "postgres-uri-with-password"},
|
||||||
|
{"private-key", "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA", "private-key"},
|
||||||
|
{"ssh-public", "deploy: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK1234567890abcdefghij user@host", "ssh-key"},
|
||||||
|
{"github-pat-classic", "GH_TOKEN=ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt", "github-pat"},
|
||||||
|
{"openai-key", "OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII", "openai-sk"},
|
||||||
|
{"anthropic-key", "ANTHROPIC_API_KEY=sk-ant-api03-aaaaBBBBccccDDDDeeeeFFFFggggHHHHiiiiJJJJkkkk", "anthropic-sk"},
|
||||||
|
{"aws-access-key", "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", "aws-access-key"},
|
||||||
|
{"homelab-env", "POSTGRES_PASSWORD=hunter2supersecretvalue", "homelab-env-token"},
|
||||||
|
{"sops-marker", "value: ENC[AES256_GCM,data:abc123def456,iv:zzz]", "sops-encrypted-marker"},
|
||||||
|
// Regression: a shell mangle glued the key to a preceding word
|
||||||
|
// ("yes"+"sk-..."), defeating the leading \b in the sk- rule and
|
||||||
|
// leaking a LiteLLM master key past the scrubber (2026-06-11).
|
||||||
|
{"sk-glued-to-word", "master key resolved: yessk-7181ca984603239d8c4819361bf33b94b9c3c07018791868", "openai-sk"},
|
||||||
|
{"sk-standalone-hex", "sk-7181ca984603239d8c4819361bf33b94b9c3c07018791868", "openai-sk"},
|
||||||
|
// Bare JWT not preceded by "Bearer" (e.g. a Dex token dumped to stdout).
|
||||||
|
{"jwt-bare", "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQabcdef", "jwt"},
|
||||||
|
// 1Password service-account token (ops_<base64url>), env-assigned and bare.
|
||||||
|
// Both hit the dedicated op-service-account rule (ordered before the
|
||||||
|
// generic homelab-env-token). Guards ~/.zshrc reads etc. (2026-06-14).
|
||||||
|
{"op-sa-env", "export OP_SERVICE_ACCOUNT_TOKEN=ops_eyJzaWduSW5BZGRyZXNzIjoibXkuMXBhc3N3b3JkLmNvbSJ9", "op-service-account"},
|
||||||
|
{"op-sa-bare", "ops_eyJzaWduSW5BZGRyZXNzIjoibXkuMXBhc3N3b3JkLmNvbSIsInVzZXJBdXRoIjp7fX0aGVsbG8", "op-service-account"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := Scrub(tc.content)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"",
|
||||||
|
"plain text with no credentials",
|
||||||
|
"a 40 char hex string aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is fine in isolation",
|
||||||
|
"`Bearer` token mentioned in docs without an actual value",
|
||||||
|
"file at ~/.ssh/id_ed25519",
|
||||||
|
"the function Authorization() takes no args",
|
||||||
|
"comment: see API key in 1Password",
|
||||||
|
// loosened sk- rule must not trip on short "task-"/"disk-" words
|
||||||
|
"run task-build then task-test in the pipeline",
|
||||||
|
"mounted /dev/disk-by-id/wwn-0x5000",
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
assert.Empty(t, Scrub(c), "expected clean for %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrub_FirstMatchWins(t *testing.T) {
|
||||||
|
// Content matching multiple rules: report the first rule order in
|
||||||
|
// DefaultRules. Stability matters for log triage.
|
||||||
|
content := "Authorization: Bearer ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt"
|
||||||
|
assert.Equal(t, "authorization-header", Scrub(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRule_ClientNameGuard(t *testing.T) {
|
||||||
|
t.Cleanup(ResetExtraRules)
|
||||||
|
require := func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(RegisterRule("client-name", `(?i)\b(SEB|Mastercard)\b`))
|
||||||
|
|
||||||
|
// Hits — case variations + word-boundary respect.
|
||||||
|
for _, hit := range []string{
|
||||||
|
"mentioned SEB in this commit",
|
||||||
|
"the Mastercard project deadline",
|
||||||
|
"working on mastercard scope",
|
||||||
|
"SEB internal review",
|
||||||
|
} {
|
||||||
|
assert.Equal(t, "client-name", Scrub(hit), "should match %q", hit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misses — substring within a longer word should NOT match
|
||||||
|
// thanks to \b. "Sebastian" contains "seb" but \b prevents hit.
|
||||||
|
for _, miss := range []string{
|
||||||
|
"Sebastian wrote the docs",
|
||||||
|
"unrelated text",
|
||||||
|
"researcher",
|
||||||
|
"https://example.com/search?seb=1", // 'seb' bounded by ?=, still matches \b
|
||||||
|
} {
|
||||||
|
got := Scrub(miss)
|
||||||
|
if miss == "https://example.com/search?seb=1" {
|
||||||
|
// `seb=` has word-boundary at '='; this DOES match \bseb\b.
|
||||||
|
// Accept either outcome; document the tradeoff.
|
||||||
|
assert.Contains(t, []string{"", "client-name"}, got)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Empty(t, got, "should NOT match %q", miss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRule_CredentialsTakePrecedence(t *testing.T) {
|
||||||
|
t.Cleanup(ResetExtraRules)
|
||||||
|
require := func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(RegisterRule("client-name", `\b(SEB)\b`))
|
||||||
|
|
||||||
|
// Content matches both a credential rule AND a client rule —
|
||||||
|
// credential rule wins by ordering, so log triage points at the
|
||||||
|
// strictly more dangerous leak.
|
||||||
|
content := "SEB project uses OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII"
|
||||||
|
assert.Equal(t, "openai-sk", Scrub(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRule_RejectsInvalidPattern(t *testing.T) {
|
||||||
|
t.Cleanup(ResetExtraRules)
|
||||||
|
err := RegisterRule("bad", "[unclosed")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingHandler captures log records so a test can assert that a file was
|
||||||
|
// never even attempted — the absence of a read error is the evidence.
|
||||||
|
type recordingHandler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||||
|
|
||||||
|
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(r.Message)
|
||||||
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
|
sb.WriteString(" " + a.Key + "=" + a.Value.String())
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
h.records = append(h.records, sb.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||||
|
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
||||||
|
|
||||||
|
func (h *recordingHandler) contains(substr string) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for _, r := range h.records {
|
||||||
|
if strings.Contains(r, substr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(t *testing.T, root, project, id string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, project)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||||
|
return writeSession(t, dir, id, []string{
|
||||||
|
`{"type":"user","sessionId":"` + id + `","message":"a prompt"}`,
|
||||||
|
`{"type":"assistant","sessionId":"` + id + `","message":{"content":[{"type":"text","text":"an answer"}]}}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A named project is not ingested, and its neighbours still are.
|
||||||
|
func TestSkipProjects_skipsTheNamedProjectOnly(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "skipped")
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"-home-mathias-dev-AGENTS-cobalt-dingo"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1)
|
||||||
|
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||||
|
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globs are necessary, not a convenience. A project directory is derived from
|
||||||
|
// the session's WORKING DIRECTORY, so a git worktree produces an ephemeral
|
||||||
|
// name like "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC...". An
|
||||||
|
// exact-match list could never keep up with those.
|
||||||
|
func TestSkipProjects_globMatchesEphemeralWorktreeNames(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev--claude-worktrees-bridge-cse-017xoCEcrc35", "worktree")
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"*claude-worktrees-bridge-cse*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1)
|
||||||
|
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The point of skipping at the directory walk: the file is never OPENED, so
|
||||||
|
// its content never enters this process. Proven by making it unreadable — a
|
||||||
|
// skipped file produces no read error because nothing tried to read it, while
|
||||||
|
// the same file in a non-skipped project does.
|
||||||
|
func TestSkipProjects_theFileIsNeverOpened(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o000))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||||
|
|
||||||
|
rec := &recordingHandler{}
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
Logger: slog.New(rec),
|
||||||
|
SkipProjects: []string{"*cobalt-dingo*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.Empty(t, sink.batches)
|
||||||
|
assert.False(t, rec.contains("file failed"),
|
||||||
|
"a skipped file must not even be attempted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control test for the one above: without the skip, the unreadable file IS
|
||||||
|
// attempted and fails. Otherwise the assertion above could pass for the wrong
|
||||||
|
// reason.
|
||||||
|
func TestSkipProjects_withoutTheSkipTheSameFileIsAttempted(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||||
|
require.NoError(t, os.Chmod(path, 0o000))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||||
|
|
||||||
|
rec := &recordingHandler{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: &memSink{},
|
||||||
|
Logger: slog.New(rec),
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.True(t, rec.contains("file failed"),
|
||||||
|
"the unreadable file must fail when not skipped — otherwise the skip test proves nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns match the project directory NAME, not the whole path. A temp
|
||||||
|
// directory or a home directory component must not accidentally match and
|
||||||
|
// silently disable ingestion.
|
||||||
|
func TestSkipProjects_matchesTheProjectNameNotTheFullPath(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
// tmp is somewhere under /tmp; this must not match the project name.
|
||||||
|
SkipProjects: []string{"*tmp*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1, "a path component outside the project name must not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unparseable pattern must stop the watcher rather than be ignored. A
|
||||||
|
// silently dropped skip rule means ingesting what was meant to be excluded,
|
||||||
|
// which is the failure that cannot be noticed after the fact.
|
||||||
|
func TestSkipProjects_invalidPatternRefusesToRun(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
err := TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"[unclosed"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "[unclosed")
|
||||||
|
assert.Empty(t, sink.batches, "nothing may be ingested while a skip rule is broken")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipProjects_emptyListChangesNothing(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: nil,
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The difference between SkipDir and filtering after the walk, made
|
||||||
|
// observable: an unreadable skipped directory must not disturb anything.
|
||||||
|
//
|
||||||
|
// Without SkipDir, WalkDir reads the directory's entries, gets a permission
|
||||||
|
// error, and that error aborts the ENTIRE walk — so one skipped project with
|
||||||
|
// awkward permissions would silently stop ingesting every other project.
|
||||||
|
// Returning SkipDir means its contents are never listed at all.
|
||||||
|
//
|
||||||
|
// Added after a mutation test showed the earlier assertions could not tell the
|
||||||
|
// two implementations apart.
|
||||||
|
func TestSkipProjects_anUnreadableSkippedDirDoesNotBreakTheWalk(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
session(t, tmp, "-home-mathias-dev", "kept")
|
||||||
|
|
||||||
|
blocked := filepath.Join(tmp, "-home-mathias-dev-AGENTS-cobalt-dingo")
|
||||||
|
require.NoError(t, os.MkdirAll(blocked, 0o755))
|
||||||
|
_ = writeSession(t, blocked, "secret", []string{
|
||||||
|
`{"type":"user","sessionId":"secret","message":"should never be read"}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, os.Chmod(blocked, 0o000))
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) })
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
SkipProjects: []string{"*cobalt-dingo*"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 1, "an unreadable skipped directory must not abort the walk")
|
||||||
|
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sink consumes batches of ingest-ready turns from the watcher. The
|
||||||
|
// production implementation builds wiki pages and calls pipeline.RunRaw
|
||||||
|
// against the brain. Tests substitute a counter.
|
||||||
|
//
|
||||||
|
// A Batch represents the turns ingested from one session file between
|
||||||
|
// two cursor checkpoints. Implementations must be idempotent — the
|
||||||
|
// watcher only advances the cursor on a nil return.
|
||||||
|
type Sink interface {
|
||||||
|
Ingest(ctx context.Context, b Batch) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch is a per-file slice of turns plus identifying metadata.
|
||||||
|
type Batch struct {
|
||||||
|
Host string // origin host, e.g. "koala"
|
||||||
|
FilePath string // absolute path to the source .jsonl file
|
||||||
|
SessionID string // first session_id seen in the batch
|
||||||
|
ProjectID string // basename of the parent dir, e.g. "-home-mathias-dev"
|
||||||
|
Turns []Turn // never empty; caller filters Skip + scrubber matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config drives one Watch loop. SessionsDir is the absolute path to the
|
||||||
|
// Claude Code projects directory (~/.claude/projects). Host is the
|
||||||
|
// label written into cursors and ingested page frontmatter. Interval
|
||||||
|
// is the poll cadence; a zero or negative value disables the loop.
|
||||||
|
//
|
||||||
|
// Sink is required. Cursors is optional — when nil the watcher
|
||||||
|
// re-reads from byte 0 on every tick (useful for first-run testing
|
||||||
|
// without a postgres dependency).
|
||||||
|
type Config struct {
|
||||||
|
SessionsDir string
|
||||||
|
Host string
|
||||||
|
Interval time.Duration
|
||||||
|
Sink Sink
|
||||||
|
Cursors *CursorStore
|
||||||
|
Logger *slog.Logger
|
||||||
|
|
||||||
|
// SkipProjects excludes whole project directories from ingestion, as
|
||||||
|
// filepath.Match globs tested against the project directory NAME (the
|
||||||
|
// basename, e.g. "-home-mathias-dev"), not the full path.
|
||||||
|
//
|
||||||
|
// Globs rather than exact names because a project directory is derived
|
||||||
|
// from the session's working directory, so a git worktree yields an
|
||||||
|
// ephemeral name like
|
||||||
|
// "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC..." that no
|
||||||
|
// exact-match list could keep up with.
|
||||||
|
//
|
||||||
|
// Matching directories are skipped during the directory WALK, so their
|
||||||
|
// files are never opened and their content never enters this process —
|
||||||
|
// which is a stronger property than filtering it out afterwards.
|
||||||
|
//
|
||||||
|
// An unparseable pattern stops the watcher. A silently dropped skip rule
|
||||||
|
// means ingesting what was meant to be excluded, and that is not
|
||||||
|
// noticeable after the fact.
|
||||||
|
SkipProjects []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
||||||
|
// on shutdown. Each tick walks SessionsDir for *.jsonl files, advances
|
||||||
|
// each file's cursor, and emits one Batch per file with new turns.
|
||||||
|
// Errors during a single file's parse or ingest are logged but do not
|
||||||
|
// abort the loop — a single bad file shouldn't block the others.
|
||||||
|
func Watch(ctx context.Context, cfg Config) error {
|
||||||
|
if cfg.SessionsDir == "" {
|
||||||
|
return fmt.Errorf("sessions dir is required")
|
||||||
|
}
|
||||||
|
if cfg.Sink == nil {
|
||||||
|
return fmt.Errorf("sink is required")
|
||||||
|
}
|
||||||
|
if cfg.Interval <= 0 {
|
||||||
|
return fmt.Errorf("interval must be positive")
|
||||||
|
}
|
||||||
|
if cfg.Host == "" {
|
||||||
|
cfg.Host = "unknown"
|
||||||
|
}
|
||||||
|
if cfg.Logger == nil {
|
||||||
|
cfg.Logger = slog.Default()
|
||||||
|
}
|
||||||
|
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Logger.Info("claudewatcher: started",
|
||||||
|
"sessions_dir", cfg.SessionsDir,
|
||||||
|
"host", cfg.Host,
|
||||||
|
"interval", cfg.Interval)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(cfg.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
// Run an immediate first sweep so first-launch users don't wait one
|
||||||
|
// tick before anything happens.
|
||||||
|
runTick(ctx, cfg)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
runTick(ctx, cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTick is one polling pass. Exposed (lowercase) for tests via
|
||||||
|
// TickOnce.
|
||||||
|
func runTick(ctx context.Context, cfg Config) {
|
||||||
|
files, err := listSessionFiles(cfg.SessionsDir, cfg.SkipProjects, cfg.Logger)
|
||||||
|
if err != nil {
|
||||||
|
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := processFile(ctx, cfg, f); err != nil {
|
||||||
|
cfg.Logger.Warn("claudewatcher: file failed",
|
||||||
|
"path", f, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TickOnce runs one sweep synchronously and returns. Used by tests +
|
||||||
|
// by ad-hoc CLI invocations.
|
||||||
|
func TickOnce(ctx context.Context, cfg Config) error {
|
||||||
|
if cfg.SessionsDir == "" || cfg.Sink == nil {
|
||||||
|
return fmt.Errorf("config invalid")
|
||||||
|
}
|
||||||
|
if cfg.Host == "" {
|
||||||
|
cfg.Host = "unknown"
|
||||||
|
}
|
||||||
|
if cfg.Logger == nil {
|
||||||
|
cfg.Logger = slog.Default()
|
||||||
|
}
|
||||||
|
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runTick(ctx, cfg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSkipPatterns rejects a pattern filepath.Match cannot parse.
|
||||||
|
//
|
||||||
|
// Checked up front rather than per file: a bad pattern that only failed at
|
||||||
|
// match time would be reported once per session file per tick, and — worse —
|
||||||
|
// filepath.Match returns an error alongside a false match, so the directory
|
||||||
|
// would be ingested while the log complained.
|
||||||
|
func validateSkipPatterns(patterns []string) error {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if _, err := filepath.Match(p, "probe"); err != nil {
|
||||||
|
return fmt.Errorf("invalid skip-project pattern %q: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipProject reports whether a project directory name matches any pattern.
|
||||||
|
func skipProject(name string, patterns []string) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
// The error case is already excluded by validateSkipPatterns.
|
||||||
|
if ok, _ := filepath.Match(p, name); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSessionFiles(root string, skip []string, log *slog.Logger) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
// SkipDir, so nothing inside is even listed. The files are never
|
||||||
|
// opened and their content never reaches this process.
|
||||||
|
if path != root && skipProject(d.Name(), skip) {
|
||||||
|
log.Info("claudewatcher: project skipped by configuration", "project", d.Name())
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".jsonl") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("walk %s: %w", root, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processFile(ctx context.Context, cfg Config, path string) error {
|
||||||
|
startOffset := int64(0)
|
||||||
|
if cfg.Cursors != nil {
|
||||||
|
off, _, err := cfg.Cursors.GetOffset(ctx, cfg.Host, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get cursor: %w", err)
|
||||||
|
}
|
||||||
|
startOffset = off
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat: %w", err)
|
||||||
|
}
|
||||||
|
if stat.Size() <= startOffset {
|
||||||
|
return nil // nothing new
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
if _, err := f.Seek(startOffset, 0); err != nil {
|
||||||
|
return fmt.Errorf("seek: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keep []Turn
|
||||||
|
var sessionID string
|
||||||
|
var droppedScrub int
|
||||||
|
endOffset, err := ParseStream(f, startOffset,
|
||||||
|
func(format string, args ...any) {
|
||||||
|
cfg.Logger.Warn(fmt.Sprintf("claudewatcher: parse: "+format, args...))
|
||||||
|
},
|
||||||
|
func(t Turn) error {
|
||||||
|
if t.Skip || t.Content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rule := Scrub(t.Content); rule != "" {
|
||||||
|
droppedScrub++
|
||||||
|
cfg.Logger.Warn("claudewatcher: turn dropped by scrubber",
|
||||||
|
"rule", rule, "path", path, "session_id", t.SessionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = t.SessionID
|
||||||
|
}
|
||||||
|
keep = append(keep, t)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keep) == 0 {
|
||||||
|
if cfg.Cursors != nil {
|
||||||
|
if err := cfg.Cursors.SetOffset(ctx, cfg.Host, path, endOffset); err != nil {
|
||||||
|
return fmt.Errorf("advance cursor (no-turns): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if droppedScrub > 0 {
|
||||||
|
cfg.Logger.Info("claudewatcher: only scrubbed turns this tick",
|
||||||
|
"path", path, "dropped", droppedScrub)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := Batch{
|
||||||
|
Host: cfg.Host,
|
||||||
|
FilePath: path,
|
||||||
|
SessionID: sessionID,
|
||||||
|
ProjectID: filepath.Base(filepath.Dir(path)),
|
||||||
|
Turns: keep,
|
||||||
|
}
|
||||||
|
if err := cfg.Sink.Ingest(ctx, batch); err != nil {
|
||||||
|
return fmt.Errorf("sink ingest: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Cursors != nil {
|
||||||
|
if err := cfg.Cursors.SetOffset(ctx, cfg.Host, path, endOffset); err != nil {
|
||||||
|
return fmt.Errorf("advance cursor: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.Logger.Info("claudewatcher: ingested batch",
|
||||||
|
"path", path, "session_id", sessionID,
|
||||||
|
"turns_kept", len(keep), "dropped_scrub", droppedScrub,
|
||||||
|
"new_offset", endOffset)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package claudewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memSink captures batches without touching postgres. Thread-safe so
|
||||||
|
// TickOnce can run from any goroutine in concurrent tests.
|
||||||
|
type memSink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
batches []Batch
|
||||||
|
failOn string // file basename to error on
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memSink) Ingest(_ context.Context, b Batch) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.failOn != "" && strings.Contains(b.FilePath, m.failOn) {
|
||||||
|
return assert.AnError
|
||||||
|
}
|
||||||
|
m.batches = append(m.batches, b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSession(t *testing.T, dir, sessionID string, lines []string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, sessionID+".jsonl")
|
||||||
|
body := strings.Join(lines, "\n") + "\n"
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTickOnce_NoCursorReingestsEverythingEveryTick(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
projectDir := filepath.Join(tmp, "-home-mathias-dev")
|
||||||
|
require.NoError(t, os.MkdirAll(projectDir, 0o755))
|
||||||
|
writeSession(t, projectDir, "sess1", []string{
|
||||||
|
`{"type":"user","sessionId":"sess1","message":"first prompt"}`,
|
||||||
|
`{"type":"assistant","sessionId":"sess1","message":{"content":[{"type":"text","text":"first answer"}]}}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
cfg := Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Sink: sink,
|
||||||
|
}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), cfg))
|
||||||
|
require.NoError(t, TickOnce(context.Background(), cfg))
|
||||||
|
|
||||||
|
require.Len(t, sink.batches, 2, "no cursor => re-emits same batch every tick")
|
||||||
|
assert.Equal(t, "sess1", sink.batches[0].SessionID)
|
||||||
|
assert.Equal(t, "koala", sink.batches[0].Host)
|
||||||
|
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
|
||||||
|
assert.Len(t, sink.batches[0].Turns, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTickOnce_FiltersSkipTurnsAndScrubberMatches(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||||
|
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||||
|
writeSession(t, proj, "sess-scrub", []string{
|
||||||
|
`{"type":"queue-operation","sessionId":"sess-scrub","content":"x"}`, // Skip
|
||||||
|
`{"type":"user","sessionId":"sess-scrub","message":"normal prompt"}`,
|
||||||
|
`{"type":"assistant","sessionId":"sess-scrub","message":{"content":[{"type":"text","text":"value POSTGRES_PASSWORD=hunter2supersecretvalue"}]}}`, // scrubbed
|
||||||
|
})
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||||
|
}))
|
||||||
|
require.Len(t, sink.batches, 1)
|
||||||
|
turns := sink.batches[0].Turns
|
||||||
|
require.Len(t, turns, 1, "skip + scrubbed turns must not reach the sink")
|
||||||
|
assert.Equal(t, "user", turns[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTickOnce_AllScrubbedNoBatchEmitted(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||||
|
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||||
|
writeSession(t, proj, "all-bad", []string{
|
||||||
|
`{"type":"user","sessionId":"all-bad","message":"Authorization: Bearer abcdef1234567890ghijklmnop"}`,
|
||||||
|
})
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||||
|
}))
|
||||||
|
assert.Empty(t, sink.batches, "no usable turns => no batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTickOnce_IgnoresNonJsonlFiles(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||||
|
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(proj, "README.md"), []byte("ignore me"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(proj, "config.json"), []byte("{}"), 0o644))
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||||
|
}))
|
||||||
|
assert.Empty(t, sink.batches)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTickOnce_HandlesMultipleProjectsAndSessions(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
projA := filepath.Join(tmp, "-home-mathias-dev")
|
||||||
|
projB := filepath.Join(tmp, "-home-mathias-AI-infra")
|
||||||
|
require.NoError(t, os.MkdirAll(projA, 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(projB, 0o755))
|
||||||
|
writeSession(t, projA, "a1", []string{`{"type":"user","sessionId":"a1","message":"q1"}`})
|
||||||
|
writeSession(t, projA, "a2", []string{`{"type":"user","sessionId":"a2","message":"q2"}`})
|
||||||
|
writeSession(t, projB, "b1", []string{`{"type":"user","sessionId":"b1","message":"q3"}`})
|
||||||
|
|
||||||
|
sink := &memSink{}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||||
|
}))
|
||||||
|
require.Len(t, sink.batches, 3)
|
||||||
|
|
||||||
|
projects := map[string]int{}
|
||||||
|
for _, b := range sink.batches {
|
||||||
|
projects[b.ProjectID]++
|
||||||
|
}
|
||||||
|
assert.Equal(t, 2, projects["-home-mathias-dev"])
|
||||||
|
assert.Equal(t, 1, projects["-home-mathias-AI-infra"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTickOnce_SinkErrorDoesNotKillOtherFiles(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
proj := filepath.Join(tmp, "-home-mathias-dev")
|
||||||
|
require.NoError(t, os.MkdirAll(proj, 0o755))
|
||||||
|
writeSession(t, proj, "good", []string{`{"type":"user","sessionId":"good","message":"q"}`})
|
||||||
|
writeSession(t, proj, "bad-session", []string{`{"type":"user","sessionId":"bad-session","message":"q"}`})
|
||||||
|
|
||||||
|
sink := &memSink{failOn: "bad-session"}
|
||||||
|
require.NoError(t, TickOnce(context.Background(), Config{
|
||||||
|
SessionsDir: tmp, Host: "koala", Sink: sink,
|
||||||
|
}))
|
||||||
|
require.Len(t, sink.batches, 1, "good session still ingested")
|
||||||
|
assert.Equal(t, "good", sink.batches[0].SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatch_RespectsContextCancel(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(tmp, "-home-mathias-dev"), 0o755))
|
||||||
|
sink := &memSink{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- Watch(ctx, Config{
|
||||||
|
SessionsDir: tmp,
|
||||||
|
Host: "koala",
|
||||||
|
Interval: 10 * time.Millisecond,
|
||||||
|
Sink: sink,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Watch did not return after cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// ingestion/internal/extract/docmark.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// docmarkRequest is a JSON-RPC 2.0 tools/call request for docmark's
|
||||||
|
// convert_to_markdown tool.
|
||||||
|
type docmarkRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments struct {
|
||||||
|
ContentBase64 string `json:"content_base64"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
} `json:"arguments"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type docmarkResponse struct {
|
||||||
|
Result *struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
} `json:"result"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractViaDocmark converts path (PDF/DOCX/XLSX/PPTX/image) to Markdown by
|
||||||
|
// calling the docmark MCP server with a single self-contained tools/call
|
||||||
|
// request (docmark runs stateless_http -- no initialize handshake or session
|
||||||
|
// ID needed). DOCMARK_URL must be set (e.g.
|
||||||
|
// http://docmark.docmark.svc.cluster.local:3001/mcp); DOCMARK_BEARER_TOKEN
|
||||||
|
// is docmark's static bearer (network is docmark's primary auth boundary,
|
||||||
|
// this is defense-in-depth — ADR-0013).
|
||||||
|
func extractViaDocmark(path string) (string, error) {
|
||||||
|
url := os.Getenv("DOCMARK_URL")
|
||||||
|
if url == "" {
|
||||||
|
return "", fmt.Errorf("extractViaDocmark: DOCMARK_URL is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody docmarkRequest
|
||||||
|
reqBody.JSONRPC = "2.0"
|
||||||
|
reqBody.ID = 1
|
||||||
|
reqBody.Method = "tools/call"
|
||||||
|
reqBody.Params.Name = "convert_to_markdown"
|
||||||
|
reqBody.Params.Arguments.ContentBase64 = base64.StdEncoding.EncodeToString(raw)
|
||||||
|
reqBody.Params.Arguments.Filename = fileBase(path)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal docmark request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build docmark request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
if tok := os.Getenv("DOCMARK_BEARER_TOKEN"); tok != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call docmark: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read docmark response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("docmark: HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody := body
|
||||||
|
if data := sseDataPayload(body); data != nil {
|
||||||
|
jsonBody = data
|
||||||
|
}
|
||||||
|
|
||||||
|
var out docmarkResponse
|
||||||
|
if err := json.Unmarshal(jsonBody, &out); err != nil {
|
||||||
|
return "", fmt.Errorf("decode docmark response: %w", err)
|
||||||
|
}
|
||||||
|
if out.Error != nil {
|
||||||
|
return "", fmt.Errorf("docmark: %s", out.Error.Message)
|
||||||
|
}
|
||||||
|
if out.Result == nil || len(out.Result.Content) == 0 {
|
||||||
|
return "", fmt.Errorf("docmark: empty response")
|
||||||
|
}
|
||||||
|
text := out.Result.Content[0].Text
|
||||||
|
if out.Result.IsError {
|
||||||
|
return "", fmt.Errorf("docmark: %s", text)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseDataPayload extracts the JSON payload from an SSE-framed response body
|
||||||
|
// ("event: message\r\ndata: {...}\r\n\r\n"). docmark's Streamable-HTTP
|
||||||
|
// transport frames every response this way (Content-Type: text/event-stream)
|
||||||
|
// regardless of stateless_http — that flag removes the session/initialize
|
||||||
|
// requirement, not the SSE wire framing. Returns nil if body isn't SSE-framed
|
||||||
|
// (e.g. a plain-JSON response, kept as a fallback for forward-compatibility).
|
||||||
|
func sseDataPayload(body []byte) []byte {
|
||||||
|
const prefix = "data: "
|
||||||
|
for _, line := range bytes.Split(body, []byte("\n")) {
|
||||||
|
line = bytes.TrimRight(line, "\r")
|
||||||
|
if bytes.HasPrefix(line, []byte(prefix)) {
|
||||||
|
return bytes.TrimPrefix(line, []byte(prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileBase returns the final path segment (like filepath.Base, kept local to
|
||||||
|
// avoid importing path/filepath just for this one call).
|
||||||
|
func fileBase(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '/' || path[i] == '\\' {
|
||||||
|
return path[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// ingestion/internal/extract/docmark_test.go
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpToolResult mirrors the shape of docmark's JSON-RPC tools/call response.
|
||||||
|
type mcpToolResult struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result *struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
} `json:"result,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMCPResponse mirrors docmark's REAL response framing (empirically
|
||||||
|
// confirmed against the live server): Content-Type: text/event-stream,
|
||||||
|
// body is SSE-framed ("event: message\r\ndata: {...}\r\n\r\n"), not bare
|
||||||
|
// JSON -- inherent to MCP Streamable-HTTP, independent of stateless_http.
|
||||||
|
func writeMCPResponse(w http.ResponseWriter, body mcpToolResult) {
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("event: message\r\ndata: "))
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
_, _ = w.Write([]byte("\r\n\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_Success(t *testing.T) {
|
||||||
|
var gotAuth, gotAccept string
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotAccept = r.Header.Get("Accept")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &gotBody)
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "# Converted\n\nhello"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "test-token-123")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("fake docx bytes"), 0o644))
|
||||||
|
|
||||||
|
got, err := extractViaDocmark(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# Converted\n\nhello", got)
|
||||||
|
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
||||||
|
assert.Contains(t, gotAccept, "application/json")
|
||||||
|
params, _ := gotBody["params"].(map[string]any)
|
||||||
|
require.NotNil(t, params)
|
||||||
|
assert.Equal(t, "convert_to_markdown", params["name"])
|
||||||
|
args, _ := params["arguments"].(map[string]any)
|
||||||
|
require.NotNil(t, args)
|
||||||
|
assert.Equal(t, "doc.docx", args["filename"])
|
||||||
|
assert.NotEmpty(t, args["content_base64"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_NotConfigured(t *testing.T) {
|
||||||
|
t.Setenv("DOCMARK_URL", "")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "DOCMARK_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_ToolErrorSurfacesMessage(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "unsupported format for 'doc.docx'"}},
|
||||||
|
IsError: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unsupported format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractViaDocmark_HTTPErrorSurfaces(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("unauthorized"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "wrong")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "doc.docx")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
|
||||||
|
_, err := extractViaDocmark(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "401")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestText_RoutesDocxXlsxPptxImagesToDocmark(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeMCPResponse(w, mcpToolResult{
|
||||||
|
JSONRPC: "2.0", ID: 1,
|
||||||
|
Result: &struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}{
|
||||||
|
Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{{Type: "text", Text: "converted"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("DOCMARK_URL", srv.URL+"/mcp")
|
||||||
|
t.Setenv("DOCMARK_BEARER_TOKEN", "tok")
|
||||||
|
|
||||||
|
for _, ext := range []string{".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg"} {
|
||||||
|
t.Run(ext, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "f"+ext)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0o644))
|
||||||
|
got, err := Text(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "converted", got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Text reads the file at path and returns its plain-text content.
|
// Text reads the file at path and returns its plain-text content.
|
||||||
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
|
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext),
|
||||||
|
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg (via docmark, ADR-0013 -- requires
|
||||||
|
// DOCMARK_URL to be set; see docmark.go).
|
||||||
func Text(path string) (string, error) {
|
func Text(path string) (string, error) {
|
||||||
ext := strings.ToLower(fileExt(path))
|
ext := strings.ToLower(fileExt(path))
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -20,6 +22,8 @@ func Text(path string) (string, error) {
|
|||||||
return string(b), nil
|
return string(b), nil
|
||||||
case ".pdf":
|
case ".pdf":
|
||||||
return extractPDF(path)
|
return extractPDF(path)
|
||||||
|
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
|
return extractViaDocmark(path)
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Package frontmatter lints and repairs the YAML frontmatter block of a
|
||||||
|
// markdown note.
|
||||||
|
//
|
||||||
|
// Notes written directly — hand-authored, or promoted from brain/raw/ — do not
|
||||||
|
// pass through pipeline.BuildPages, so nothing quotes their scalars. A title
|
||||||
|
// containing an unescaped ": " then produces a block that yaml.Unmarshal
|
||||||
|
// rejects with "mapping values are not allowed here", and every consumer that
|
||||||
|
// reads frontmatter silently skips the note.
|
||||||
|
//
|
||||||
|
// Fix is deliberately conservative: it only adds quoting, never reorders or
|
||||||
|
// rewrites, and it refuses to return a document that still does not parse.
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fence = "---"
|
||||||
|
|
||||||
|
// Lint reports whether doc's frontmatter block parses as YAML.
|
||||||
|
//
|
||||||
|
// A document with no frontmatter block, or with an unterminated one, is not an
|
||||||
|
// error: there is nothing claiming to be YAML.
|
||||||
|
func Lint(doc []byte) error {
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(b, &v); err != nil {
|
||||||
|
return fmt.Errorf("frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix quotes unquoted scalars whose value breaks YAML parsing and returns the
|
||||||
|
// repaired document.
|
||||||
|
//
|
||||||
|
// It returns (doc, false, nil) when the document already parses, and
|
||||||
|
// (doc, false, err) when quoting cannot rescue it — an unfixable document is
|
||||||
|
// always returned byte-identical, never half-repaired.
|
||||||
|
func Fix(doc []byte) ([]byte, bool, error) {
|
||||||
|
if err := Lint(doc); err == nil {
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
// Lint only fails when there is a block, so this is unreachable in
|
||||||
|
// practice; return the original rather than guess.
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed := quoteScalars(b)
|
||||||
|
if bytes.Equal(fixed, b) {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: not repairable by quoting: %w", Lint(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(fixed, &v); err != nil {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rest, _ := split(doc)
|
||||||
|
out := []byte(fence + "\n")
|
||||||
|
out = append(out, fixed...)
|
||||||
|
out = append(out, []byte(fence)...)
|
||||||
|
out = append(out, rest...)
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mappingLine matches "key: value" at any indentation, capturing the prefix so
|
||||||
|
// it can be re-emitted untouched.
|
||||||
|
//
|
||||||
|
// Sequence items ("- value") are deliberately not handled: "- foo: bar" is
|
||||||
|
// valid YAML, so a document containing one does not reach Fix in the first
|
||||||
|
// place. See the Lint table for why that gap is left open.
|
||||||
|
var mappingLine = regexp.MustCompile(`^(\s*[A-Za-z_][\w.-]*:[ \t]+)(\S.*)$`)
|
||||||
|
|
||||||
|
// quoteScalars single-quotes every unquoted scalar value in the block that
|
||||||
|
// contains a colon. Values that are already quoted, or that carry YAML syntax
|
||||||
|
// this package has no business rewriting, are left alone.
|
||||||
|
func quoteScalars(block []byte) []byte {
|
||||||
|
lines := strings.Split(string(block), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
m := mappingLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix, value := m[1], m[2]
|
||||||
|
if !needsQuoting(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines[i] = prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsQuoting reports whether a scalar value has to be quoted to load as the
|
||||||
|
// string it plainly is.
|
||||||
|
//
|
||||||
|
// The test is YAML's own parser rather than a pattern: ask whether the value
|
||||||
|
// stands alone as the right-hand side of a mapping. Heuristics get this wrong
|
||||||
|
// in both directions — `title: "Foo" — bar` opens with a quote but is not
|
||||||
|
// quoted, and a value may contain a colon inside quotes and be perfectly fine.
|
||||||
|
//
|
||||||
|
// Anything that already parses is left alone, whatever it parses into — a flow
|
||||||
|
// collection, a number, a bool. It is not broken, and quoting would change its
|
||||||
|
// type.
|
||||||
|
func needsQuoting(value string) bool {
|
||||||
|
if strings.HasPrefix(value, "|") || strings.HasPrefix(value, ">") {
|
||||||
|
return false // block scalar; its content is not parsed as YAML
|
||||||
|
}
|
||||||
|
var probe map[string]any
|
||||||
|
return yaml.Unmarshal([]byte("v: "+value+"\n"), &probe) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// block returns the raw YAML between the opening and closing fences.
|
||||||
|
func block(doc []byte) ([]byte, bool) {
|
||||||
|
b, _, ok := split(doc)
|
||||||
|
return b, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// split returns the frontmatter block and everything from the closing fence
|
||||||
|
// onward (the closing fence itself excluded, its trailing newline included).
|
||||||
|
func split(doc []byte) (block, rest []byte, ok bool) {
|
||||||
|
s := string(doc)
|
||||||
|
if !strings.HasPrefix(s, fence+"\n") {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
body := s[len(fence)+1:]
|
||||||
|
|
||||||
|
idx := strings.Index(body, "\n"+fence)
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// ingestion/internal/frontmatter/frontmatter_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no frontmatter block is not an error",
|
||||||
|
doc: "# Just a heading\n\nSome body text.\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted title parses",
|
||||||
|
doc: "---\ntitle: 'Headless dispatch runs: Bash is denied'\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain title parses",
|
||||||
|
doc: "---\ntitle: A simple title\nwing: homelab\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted colon in title does not parse",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Known gap, deliberately not fixed here: this parses, but the
|
||||||
|
// item becomes map[string]any{"Headless dispatch": "denied"}
|
||||||
|
// rather than a string. It is a silent type corruption, not a
|
||||||
|
// parse failure, so repairing it would rewrite documents that
|
||||||
|
// currently load — out of scope for a lint about broken YAML.
|
||||||
|
name: "unquoted colon in a list item parses as a mapping, not an error",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated frontmatter is not treated as frontmatter",
|
||||||
|
doc: "---\ntitle: no closing fence\n\nbody\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := Lint([]byte(tt.doc))
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantChanged bool
|
||||||
|
wantKey string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "quotes an unquoted title containing a colon",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Headless dispatch runs: Bash is denied — use gitea MCP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Parses already (see the Lint table), so Fix must leave it alone
|
||||||
|
// rather than rewrite a document that loads.
|
||||||
|
name: "leaves a colon-bearing list item alone because it already parses",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escapes an embedded single quote when quoting",
|
||||||
|
doc: "---\ntitle: Don't do this: it breaks\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Don't do this: it breaks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The value opens with a double quote and then keeps going after
|
||||||
|
// the closing one, so YAML reads it as a quoted scalar followed by
|
||||||
|
// junk. Treating "starts with a quote" as "already quoted" leaves
|
||||||
|
// this broken forever.
|
||||||
|
name: "quotes a value that only looks quoted",
|
||||||
|
doc: "---\ntitle: \"Evaluation Driven Generation\" — a TDD-style scaffold\ntags: [a, b]\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "\"Evaluation Driven Generation\" — a TDD-style scaffold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves an already-valid document untouched",
|
||||||
|
doc: "---\ntitle: 'Already quoted: fine'\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Already quoted: fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves a document with no frontmatter untouched",
|
||||||
|
doc: "# Heading\n\nbody: with a colon\n",
|
||||||
|
wantChanged: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, changed, err := Fix([]byte(tt.doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantChanged, changed)
|
||||||
|
|
||||||
|
if !tt.wantChanged {
|
||||||
|
assert.Equal(t, tt.doc, string(got), "unchanged docs must be returned byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever came back must parse — that is the whole point.
|
||||||
|
require.NoError(t, Lint(got), "Fix must not return a document that still fails to parse")
|
||||||
|
|
||||||
|
if tt.wantKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block, ok := block(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal(block, &m))
|
||||||
|
assert.Equal(t, tt.wantValue, m[tt.wantKey], "the value must survive quoting unchanged")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_LeavesStructuredValuesStructured(t *testing.T) {
|
||||||
|
// Only the broken scalar may be touched. A flow sequence on a neighbouring
|
||||||
|
// line still has to load as a sequence afterwards, not as a string.
|
||||||
|
doc := "---\ntitle: Broken: here\ntags: [prompting, llm]\ncount: 3\n---\n\nbody\n"
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
b, ok := block(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal(b, &m))
|
||||||
|
|
||||||
|
assert.Equal(t, "Broken: here", m["title"])
|
||||||
|
assert.Equal(t, []any{"prompting", "llm"}, m["tags"])
|
||||||
|
assert.Equal(t, 3, m["count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_PreservesBodyExactly(t *testing.T) {
|
||||||
|
body := "\nThe body has: colons, 'quotes', and\n---\na horizontal rule.\n"
|
||||||
|
doc := "---\ntitle: Broken: title\n---" + body
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
assert.True(t, len(got) > 0)
|
||||||
|
|
||||||
|
_, rest, ok := split([]byte(doc))
|
||||||
|
require.True(t, ok)
|
||||||
|
_, gotRest, ok := split(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
|
||||||
|
// A frontmatter block that quoting cannot rescue: the structure itself is
|
||||||
|
// broken, not a scalar. Fix must return the original and an error rather
|
||||||
|
// than a half-repaired document.
|
||||||
|
doc := "---\ntitle: fine\n\tbad: tab-indented mapping\n - stray list item\n---\n\nbody\n"
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, changed)
|
||||||
|
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding is one markdown file whose frontmatter does not parse.
|
||||||
|
type Finding struct {
|
||||||
|
Path string // relative to the scanned root
|
||||||
|
Err error // why it does not parse, or why the repair was refused
|
||||||
|
Repaired bool // true only when fix was requested and the file was rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan walks root for *.md files whose frontmatter fails to parse.
|
||||||
|
//
|
||||||
|
// With fix set, each finding that quoting can rescue is rewritten in place;
|
||||||
|
// anything quoting cannot rescue is reported and left byte-identical. A file
|
||||||
|
// that cannot be read or decoded is reported as a finding rather than aborting
|
||||||
|
// the walk — one poisoned note must not hide the other findings.
|
||||||
|
func Scan(root string, fix bool) ([]Finding, error) {
|
||||||
|
var found []Finding
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, relErr := filepath.Rel(root, path)
|
||||||
|
if relErr != nil {
|
||||||
|
rel = path
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: readErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !utf8.Valid(doc) {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fmt.Errorf("not valid UTF-8")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if lintErr := Lint(doc); lintErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fix {
|
||||||
|
found = append(found, Finding{Path: rel, Err: Lint(doc)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired, changed, fixErr := Fix(doc)
|
||||||
|
if fixErr != nil || !changed {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fixErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if writeErr := writeFilePreservingMode(path, repaired); writeErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: writeErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found = append(found, Finding{Path: rel, Repaired: true})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan %s: %w", root, err)
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFilePreservingMode(path string, content []byte) error {
|
||||||
|
mode := fs.FileMode(0o644)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, content, mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTree(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(root, name)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
brokenDoc = "---\ntitle: Broken: title here\nwing: homelab\n---\n\nbody\n"
|
||||||
|
validDoc = "---\ntitle: 'Fine: quoted'\n---\n\nbody\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScan_ReportsOnlyBrokenFiles(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
"wiki/b/plain.md": "# no frontmatter\n\nbody: with colon\n",
|
||||||
|
"wiki/a/notes.txt": brokenDoc, // not markdown — must be ignored
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/a/broken.md", found[0].Path)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
// Dry run must not touch the file.
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, brokenDoc, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_FixWritesRepairedFilesAndLeavesTheRestAlone(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.True(t, found[0].Repaired)
|
||||||
|
assert.NoError(t, found[0].Err)
|
||||||
|
|
||||||
|
repaired, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, Lint(repaired), "the repaired file must parse")
|
||||||
|
assert.Contains(t, string(repaired), "title: 'Broken: title here'")
|
||||||
|
assert.Contains(t, string(repaired), "wing: homelab", "untouched keys must survive")
|
||||||
|
|
||||||
|
untouched, err := os.ReadFile(filepath.Join(root, "wiki/a/valid.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, validDoc, string(untouched))
|
||||||
|
|
||||||
|
// Second pass finds nothing — the repair is idempotent and complete.
|
||||||
|
again, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUnfixableWithoutWriting(t *testing.T) {
|
||||||
|
unfixable := "---\ntitle: fine\n\tbad: tab-indented\n - stray\n---\n\nbody\n"
|
||||||
|
root := writeTree(t, map[string]string{"wiki/x.md": unfixable})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, unfixable, string(got), "an unfixable file must be left exactly as it was")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUndecodableFilesInsteadOfFailingTheRun(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{"wiki/ok.md": validDoc})
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "wiki/bad-bytes.md"),
|
||||||
|
[]byte{'-', '-', '-', '\n', 0xff, 0xfe, '\n', '-', '-', '-', '\n'}, 0o644))
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err, "one unreadable file must not abort the whole scan")
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/bad-bytes.md", found[0].Path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Package gitea implements capture.IssueTracker against a Gitea instance
|
||||||
|
// over its REST API. It is the new outbound dependency the brain server
|
||||||
|
// gains for the capture capability (#49c/#52): the server otherwise does
|
||||||
|
// brain-local file ops only.
|
||||||
|
//
|
||||||
|
// Owner is hard-coded to the operator and never taken from caller input.
|
||||||
|
// The API token is read once at construction, held in the struct, and
|
||||||
|
// never logged or placed in argv — it travels only in the Authorization
|
||||||
|
// header of outbound requests (AGENTS.md secret-handling).
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
)
|
||||||
|
|
||||||
|
// owner is the fixed repository owner for every ticket operation. It is a
|
||||||
|
// constant, not a parameter, so a caller can never redirect a write to
|
||||||
|
// another owner's repo.
|
||||||
|
const owner = "mathias"
|
||||||
|
|
||||||
|
// Client is a Gitea REST API IssueTracker.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Client. It returns nil when either baseURL or token is
|
||||||
|
// empty, so callers can treat missing config as "tracker disabled" with a
|
||||||
|
// single nil check (mirrors embed.New).
|
||||||
|
func New(baseURL, token string) *Client {
|
||||||
|
if baseURL == "" || token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueResponse is the subset of a Gitea issue/comment payload we read.
|
||||||
|
type issueResponse struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIssue opens a new issue under the fixed owner.
|
||||||
|
func (c *Client) CreateIssue(ctx context.Context, repo, title, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo),
|
||||||
|
map[string]any{"title": title, "body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: out.Number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentIssue posts a comment on an existing issue.
|
||||||
|
func (c *Client) CommentIssue(ctx context.Context, repo string, number int, body string) (capture.IssueRef, error) {
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number),
|
||||||
|
map[string]any{"body": body}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseIssue closes an issue, first posting a closing comment when one is
|
||||||
|
// given (empty comment ⇒ close only).
|
||||||
|
func (c *Client) CloseIssue(ctx context.Context, repo string, number int, comment string) (capture.IssueRef, error) {
|
||||||
|
if strings.TrimSpace(comment) != "" {
|
||||||
|
if _, err := c.CommentIssue(ctx, repo, number, comment); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out issueResponse
|
||||||
|
if err := c.do(ctx, http.MethodPatch,
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number),
|
||||||
|
map[string]any{"state": "closed"}, &out); err != nil {
|
||||||
|
return capture.IssueRef{}, err
|
||||||
|
}
|
||||||
|
return capture.IssueRef{Repo: repo, Number: number, URL: out.HTMLURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile creates or updates a file in repo at path via the Gitea
|
||||||
|
// contents API — the SummaryWriter port (#66). It upserts: a GET resolves
|
||||||
|
// the current blob sha (if any) so an existing file is updated rather than
|
||||||
|
// rejected (the richer-fidelity-supersedes rule for re-captured sessions).
|
||||||
|
// Owner is the fixed const, like every other call.
|
||||||
|
func (c *Client) WriteFile(ctx context.Context, repo, path, content string) error {
|
||||||
|
cpath := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
|
||||||
|
sha, err := c.fileSHA(ctx, cpath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"message": "capture: " + path,
|
||||||
|
"content": base64.StdEncoding.EncodeToString([]byte(content)),
|
||||||
|
}
|
||||||
|
// Gitea contents API: POST creates a new file, PUT updates an existing
|
||||||
|
// one (PUT requires the current sha). Pick by whether the file exists.
|
||||||
|
method := http.MethodPost
|
||||||
|
if sha != "" {
|
||||||
|
method = http.MethodPut
|
||||||
|
payload["sha"] = sha
|
||||||
|
}
|
||||||
|
status, body, err := c.request(ctx, method, cpath, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return fmt.Errorf("gitea %s %s: status %d: %s", method, cpath, status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSHA returns the current blob sha for a contents path, or "" when the
|
||||||
|
// file does not exist (404). Any other non-2xx is an error.
|
||||||
|
func (c *Client) fileSHA(ctx context.Context, cpath string) (string, error) {
|
||||||
|
status, body, err := c.request(ctx, http.MethodGet, cpath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return "", fmt.Errorf("gitea GET %s: status %d: %s", cpath, status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var meta struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &meta); err != nil {
|
||||||
|
return "", fmt.Errorf("gitea GET %s: decode: %w", cpath, err)
|
||||||
|
}
|
||||||
|
return meta.SHA, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do performs a JSON request against the Gitea API and decodes a 2xx
|
||||||
|
// response into out. Errors carry the status and a truncated body for
|
||||||
|
// diagnosis but never the token.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path string, payload any, out *issueResponse) error {
|
||||||
|
status, body, err := c.request(ctx, method, path, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return fmt.Errorf("gitea %s %s: status %d: %s", method, path, status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
if out != nil && len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, out); err != nil {
|
||||||
|
return fmt.Errorf("gitea %s %s: decode response: %w", method, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// request is the shared HTTP path: marshals an optional JSON payload,
|
||||||
|
// attaches auth (token only ever in the header), and returns the status +
|
||||||
|
// body so callers can branch on status (e.g. 404) without it being an
|
||||||
|
// error. Never logs the token.
|
||||||
|
func (c *Client) request(ctx context.Context, method, path string, payload any) (int, []byte, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
reqBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(reqBody)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("gitea %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||||
|
return resp.StatusCode, body, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package gitea_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/gitea"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testToken = "super-secret-token-value"
|
||||||
|
|
||||||
|
func TestNewNilWhenUnconfigured(t *testing.T) {
|
||||||
|
assert.Nil(t, gitea.New("", testToken))
|
||||||
|
assert.Nil(t, gitea.New("https://git.example", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateIssueForcesOwnerAndAuth(t *testing.T) {
|
||||||
|
var gotPath, gotAuth, gotBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 42, "html_url": "https://git.d-ma.be/mathias/hyperguild/issues/42"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := gitea.New(srv.URL, testToken)
|
||||||
|
require.NotNil(t, c)
|
||||||
|
ref, err := c.CreateIssue(context.Background(), "hyperguild", "Do the thing", "details")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues", gotPath, "owner forced to mathias")
|
||||||
|
assert.Equal(t, "token "+testToken, gotAuth)
|
||||||
|
assert.Contains(t, gotBody, "Do the thing")
|
||||||
|
assert.Equal(t, "hyperguild", ref.Repo)
|
||||||
|
assert.Equal(t, 42, ref.Number)
|
||||||
|
assert.Contains(t, ref.URL, "/issues/42")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommentIssue(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"html_url": "https://git/c/1"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CommentIssue(context.Background(), "hyperguild", 7, "a comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/hyperguild/issues/7/comments", gotPath)
|
||||||
|
assert.Equal(t, 7, ref.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueWithComment(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
var states []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.Method+" "+r.URL.Path)
|
||||||
|
if r.Method == http.MethodPatch {
|
||||||
|
var body map[string]any
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &body)
|
||||||
|
states = append(states, body["state"].(string))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 9, "html_url": "https://git/i/9"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ref, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 9, "closing because done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 9, ref.Number)
|
||||||
|
// Comment posted first, then state PATCHed to closed.
|
||||||
|
assert.Contains(t, paths, "POST /api/v1/repos/mathias/hyperguild/issues/9/comments")
|
||||||
|
assert.Contains(t, paths, "PATCH /api/v1/repos/mathias/hyperguild/issues/9")
|
||||||
|
assert.Equal(t, []string{"closed"}, states)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIssueNoComment(t *testing.T) {
|
||||||
|
var commented bool
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/comments") {
|
||||||
|
commented = true
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"number": 3, "html_url": "https://git/i/3"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CloseIssue(context.Background(), "hyperguild", 3, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, commented, "empty comment ⇒ no comment POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorPathDoesNotLeakToken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := gitea.New(srv.URL, testToken).CreateIssue(context.Background(), "hyperguild", "t", "b")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), testToken, "token must never appear in an error message")
|
||||||
|
assert.Contains(t, err.Error(), "500")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileCreatesNewFile(t *testing.T) {
|
||||||
|
var getPath, postPath, postBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
getPath = r.URL.Path
|
||||||
|
w.WriteHeader(http.StatusNotFound) // file does not exist yet
|
||||||
|
case http.MethodPost: // gitea contents API: POST = create
|
||||||
|
postPath = r.URL.Path
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
postBody = string(b)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||||
|
default:
|
||||||
|
t.Errorf("create must POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(),
|
||||||
|
"ai-sessions", "summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", "# Summary\n\nbody\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/api/v1/repos/mathias/ai-sessions/contents/summaries/claude-code/2026-06/2026-06-23-x-abcd1234.md", getPath)
|
||||||
|
assert.Equal(t, getPath, postPath)
|
||||||
|
// base64 of the content, no sha on create.
|
||||||
|
assert.Contains(t, postBody, "IyBTdW1tYXJ5") // base64("# Summary")
|
||||||
|
assert.NotContains(t, postBody, `"sha"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileUpdatesExisting(t *testing.T) {
|
||||||
|
var putBody string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"sha": "deadbeef"})
|
||||||
|
case http.MethodPut:
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
putBody = string(b)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"content": map[string]any{"html_url": "https://git/x"}})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "new")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, putBody, `"sha":"deadbeef"`, "existing file → update with sha")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileErrorNoTokenLeak(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
_, _ = w.Write([]byte("bad"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
err := gitea.New(srv.URL, testToken).WriteFile(context.Background(), "ai-sessions", "p/x.md", "x")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), testToken)
|
||||||
|
assert.Contains(t, err.Error(), "422")
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// Package graph extracts entity + edge records from brain markdown
|
||||||
|
// documents for the brain_entities / brain_edges relational graph.
|
||||||
|
//
|
||||||
|
// The extractor is pure: it takes markdown bytes and a document path and
|
||||||
|
// returns the entity (one per doc) and the wikilink edges (zero or more)
|
||||||
|
// it found, with source line numbers so the graph store can record
|
||||||
|
// provenance.
|
||||||
|
//
|
||||||
|
// Edge types in v1: only "wikilink" — derived from [[slug]] and
|
||||||
|
// [[slug|Display]] occurrences in the body. Section-header edges are
|
||||||
|
// deferred (see infra#62 grill addendum).
|
||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity represents one brain document for graph indexing.
|
||||||
|
//
|
||||||
|
// Slug is the basename without ".md" — the same identity used by
|
||||||
|
// wiki canonicalization and the wikilink target syntax.
|
||||||
|
//
|
||||||
|
// Type categorises the doc into a coarse bucket so callers can filter
|
||||||
|
// graph traversals (e.g. "only entity nodes"). When the doc lives
|
||||||
|
// under brain/wiki/<wing>/<hall>/, Wing and Hall capture the
|
||||||
|
// taxonomy; otherwise they're empty (legacy brain/knowledge/ docs).
|
||||||
|
type Entity struct {
|
||||||
|
DocPath string // forward-slash, relative to brainDir
|
||||||
|
Slug string
|
||||||
|
Type string // "concept" | "entity" | "source" | "hall" | "knowledge"
|
||||||
|
Wing string // optional; from frontmatter or path
|
||||||
|
Hall string // optional; from frontmatter or path
|
||||||
|
Title string // optional; from frontmatter
|
||||||
|
// DIKW tier — infra#72. Empty until M3 migration writes `tier:`
|
||||||
|
// frontmatter to every entry. Path-inferred tier kicks in as a
|
||||||
|
// fallback so the column populates immediately on backfill even
|
||||||
|
// for entries that haven't had their frontmatter rewritten yet.
|
||||||
|
Tier string // "inbox" | "note" | "knowledge"
|
||||||
|
Topic string // kebab-slug; the thing the entry is about
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge represents a directed relationship between two slugs.
|
||||||
|
//
|
||||||
|
// SrcLine is the 1-indexed line in the source document where the link
|
||||||
|
// was found, so callers can re-find the linking text after an edit.
|
||||||
|
type Edge struct {
|
||||||
|
SrcDoc string // forward-slash, relative to brainDir
|
||||||
|
SrcSlug string // == Entity.Slug for SrcDoc
|
||||||
|
DstSlug string
|
||||||
|
EdgeType string // "wikilink" in v1
|
||||||
|
SrcLine int // 1-indexed
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkRE matches both [[slug]] and [[slug|Display Name]] wikilinks.
|
||||||
|
// Group 1 is the slug; group 2 (if present) is the display.
|
||||||
|
var linkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
|
||||||
|
|
||||||
|
// Extract parses one markdown document and returns its Entity plus the
|
||||||
|
// outgoing wikilink Edges. docPath is forward-slash, relative to
|
||||||
|
// brainDir; content is the raw markdown bytes.
|
||||||
|
//
|
||||||
|
// Returns ok=false when docPath does not yield a usable slug (e.g.
|
||||||
|
// non-markdown file slipped through).
|
||||||
|
func Extract(docPath string, content []byte) (Entity, []Edge, bool) {
|
||||||
|
slug := slugFromPath(docPath)
|
||||||
|
if slug == "" {
|
||||||
|
return Entity{}, nil, false
|
||||||
|
}
|
||||||
|
ent := Entity{DocPath: docPath, Slug: slug}
|
||||||
|
classifyByPath(&ent, docPath)
|
||||||
|
readFrontmatter(&ent, content)
|
||||||
|
inferTierFromPath(&ent, docPath)
|
||||||
|
|
||||||
|
edges := extractEdges(docPath, slug, content)
|
||||||
|
return ent, edges, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferTierFromPath fills Tier when frontmatter didn't already set it.
|
||||||
|
// The new layout has dedicated subtrees per tier; pre-migration paths
|
||||||
|
// (knowledge/, wiki/, raw/, sessions/) get their best-guess mapping so
|
||||||
|
// the column populates on backfill before the M3 file moves run.
|
||||||
|
func inferTierFromPath(e *Entity, docPath string) {
|
||||||
|
if e.Tier != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.Split(docPath, "/")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "inbox":
|
||||||
|
e.Tier = "inbox"
|
||||||
|
case "notes":
|
||||||
|
e.Tier = "note"
|
||||||
|
case "knowledge":
|
||||||
|
e.Tier = "knowledge"
|
||||||
|
case "wiki":
|
||||||
|
// Pre-M3 wiki layout. Most subdirs are I-level:
|
||||||
|
// wiki/sources/ — synth summaries of raw inbox material
|
||||||
|
// wiki/concepts/ — definitions, not lessons
|
||||||
|
// One exception: wiki/entities/ holds anchor facts about
|
||||||
|
// concrete things (models, services, people) that the eval
|
||||||
|
// expects to surface when queried directly. Those map to K
|
||||||
|
// to match the post-M3 layout target (knowledge/facts/).
|
||||||
|
if len(parts) >= 2 && parts[1] == "entities" {
|
||||||
|
e.Tier = "knowledge"
|
||||||
|
} else {
|
||||||
|
e.Tier = "note"
|
||||||
|
}
|
||||||
|
case "raw", "sessions", "clips":
|
||||||
|
e.Tier = "inbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func slugFromPath(docPath string) string {
|
||||||
|
base := filepath.Base(docPath)
|
||||||
|
if !strings.HasSuffix(base, ".md") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(base, ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyByPath fills Type / Wing / Hall from the path layout when the
|
||||||
|
// doc lives under brain/wiki/. Layout: wiki/<wing>/<hall>/<slug>.md
|
||||||
|
// or wiki/<bucket>/<slug>.md for the legacy concept/entity/source dirs.
|
||||||
|
//
|
||||||
|
// Files directly under wiki/ (no subdirectory — e.g. wiki/index.md) used
|
||||||
|
// to incorrectly land Type="hall" Wing="index.md" because the path's
|
||||||
|
// second segment was the file itself. Now they fall through to Type
|
||||||
|
// "knowledge" and leave wing/hall to frontmatter.
|
||||||
|
func classifyByPath(e *Entity, docPath string) {
|
||||||
|
parts := strings.Split(docPath, "/")
|
||||||
|
if len(parts) < 2 || parts[0] != "wiki" {
|
||||||
|
e.Type = "knowledge"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(parts) < 3 {
|
||||||
|
// wiki/<slug>.md — no subdirectory. Treat as plain knowledge
|
||||||
|
// and let frontmatter set wing/hall if they're present.
|
||||||
|
e.Type = "knowledge"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch parts[1] {
|
||||||
|
case "concepts":
|
||||||
|
e.Type = "concept"
|
||||||
|
case "entities":
|
||||||
|
e.Type = "entity"
|
||||||
|
case "sources":
|
||||||
|
e.Type = "source"
|
||||||
|
default:
|
||||||
|
// wiki/<wing>/<hall>/<slug>.md
|
||||||
|
e.Type = "hall"
|
||||||
|
e.Wing = parts[1]
|
||||||
|
if len(parts) >= 4 {
|
||||||
|
e.Hall = parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrontmatter pulls title/wing/hall from a YAML frontmatter block.
|
||||||
|
// Frontmatter is optional; missing fields leave the entity unchanged.
|
||||||
|
func readFrontmatter(e *Entity, content []byte) {
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||||
|
inFM := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
if !inFM {
|
||||||
|
inFM = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !inFM {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
switch strings.TrimSpace(key) {
|
||||||
|
case "title":
|
||||||
|
if e.Title == "" {
|
||||||
|
e.Title = v
|
||||||
|
}
|
||||||
|
case "wing":
|
||||||
|
if e.Wing == "" {
|
||||||
|
e.Wing = v
|
||||||
|
}
|
||||||
|
case "hall":
|
||||||
|
if e.Hall == "" {
|
||||||
|
e.Hall = v
|
||||||
|
}
|
||||||
|
case "tier":
|
||||||
|
if e.Tier == "" {
|
||||||
|
e.Tier = v
|
||||||
|
}
|
||||||
|
case "topic":
|
||||||
|
if e.Topic == "" {
|
||||||
|
e.Topic = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractEdges(docPath, srcSlug string, content []byte) []Edge {
|
||||||
|
var edges []Edge
|
||||||
|
seen := make(map[string]struct{}) // dedupe (dst, line)
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(content))
|
||||||
|
line := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
line++
|
||||||
|
matches := linkRE.FindAllStringSubmatch(scanner.Text(), -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
dst := strings.TrimSpace(m[1])
|
||||||
|
if dst == "" || dst == srcSlug {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := dst + "|" + itoa(line)
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
edges = append(edges, Edge{
|
||||||
|
SrcDoc: docPath,
|
||||||
|
SrcSlug: srcSlug,
|
||||||
|
DstSlug: dst,
|
||||||
|
EdgeType: "wikilink",
|
||||||
|
SrcLine: line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa avoids the fmt dependency on a hot path. Single-digit fast path
|
||||||
|
// keeps overhead negligible for typical line counts.
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtract_HallDoc(t *testing.T) {
|
||||||
|
content := []byte(`---
|
||||||
|
wing: jepa-fx
|
||||||
|
hall: decisions
|
||||||
|
title: Val Vol Decision
|
||||||
|
---
|
||||||
|
# Val Vol
|
||||||
|
|
||||||
|
See also [[other-decision]] and [[parent-concept|Parent Concept]].
|
||||||
|
|
||||||
|
Linking to [[unrelated]].
|
||||||
|
`)
|
||||||
|
|
||||||
|
ent, edges, ok := Extract("wiki/jepa-fx/decisions/val-vol.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "val-vol", ent.Slug)
|
||||||
|
assert.Equal(t, "hall", ent.Type)
|
||||||
|
assert.Equal(t, "jepa-fx", ent.Wing)
|
||||||
|
assert.Equal(t, "decisions", ent.Hall)
|
||||||
|
assert.Equal(t, "Val Vol Decision", ent.Title)
|
||||||
|
|
||||||
|
require.Len(t, edges, 3)
|
||||||
|
assert.Equal(t, "other-decision", edges[0].DstSlug)
|
||||||
|
assert.Equal(t, "parent-concept", edges[1].DstSlug)
|
||||||
|
assert.Equal(t, "unrelated", edges[2].DstSlug)
|
||||||
|
for _, e := range edges {
|
||||||
|
assert.Equal(t, "wikilink", e.EdgeType)
|
||||||
|
assert.Equal(t, "val-vol", e.SrcSlug)
|
||||||
|
assert.Equal(t, "wiki/jepa-fx/decisions/val-vol.md", e.SrcDoc)
|
||||||
|
assert.Greater(t, e.SrcLine, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_LegacyConceptDoc(t *testing.T) {
|
||||||
|
content := []byte(`---
|
||||||
|
title: Hash Encoding
|
||||||
|
---
|
||||||
|
# Hash Encoding
|
||||||
|
|
||||||
|
Linked to [[financial-sentiment-analysis|FSA]].
|
||||||
|
`)
|
||||||
|
ent, edges, ok := Extract("wiki/concepts/hash-encoding.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "hash-encoding", ent.Slug)
|
||||||
|
assert.Equal(t, "concept", ent.Type)
|
||||||
|
assert.Empty(t, ent.Wing)
|
||||||
|
assert.Empty(t, ent.Hall)
|
||||||
|
assert.Equal(t, "Hash Encoding", ent.Title)
|
||||||
|
|
||||||
|
require.Len(t, edges, 1)
|
||||||
|
assert.Equal(t, "financial-sentiment-analysis", edges[0].DstSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_KnowledgeDoc(t *testing.T) {
|
||||||
|
content := []byte("# No frontmatter, no links here.\n")
|
||||||
|
ent, edges, ok := Extract("knowledge/some-note.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "some-note", ent.Slug)
|
||||||
|
assert.Equal(t, "knowledge", ent.Type)
|
||||||
|
assert.Empty(t, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_DedupesRepeatedLinkOnSameLine(t *testing.T) {
|
||||||
|
content := []byte("See [[foo]] and [[foo]] again on the same line.\n")
|
||||||
|
_, edges, ok := Extract("knowledge/dup.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, edges, 1)
|
||||||
|
assert.Equal(t, "foo", edges[0].DstSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_KeepsMultipleEdgesOnDifferentLines(t *testing.T) {
|
||||||
|
content := []byte("First mention [[foo]].\n\nSecond mention [[foo]].\n")
|
||||||
|
_, edges, ok := Extract("knowledge/multi.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, edges, 2)
|
||||||
|
assert.NotEqual(t, edges[0].SrcLine, edges[1].SrcLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_IgnoresSelfLinks(t *testing.T) {
|
||||||
|
content := []byte("Self-reference [[self]] should be ignored.\n")
|
||||||
|
_, edges, ok := Extract("knowledge/self.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Empty(t, edges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_RejectsNonMarkdown(t *testing.T) {
|
||||||
|
_, _, ok := Extract("wiki/concepts/not-markdown.txt", []byte("anything"))
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_LineNumbersAre1Indexed(t *testing.T) {
|
||||||
|
content := []byte("line 1\nline 2 [[bar]]\n")
|
||||||
|
_, edges, ok := Extract("knowledge/lines.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, edges, 1)
|
||||||
|
assert.Equal(t, 2, edges[0].SrcLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files directly under wiki/ (no subdirectory) used to land
|
||||||
|
// Type="hall" Wing="<filename>.md" because the path's second segment
|
||||||
|
// was the file itself. The fix routes them to Type="knowledge" with
|
||||||
|
// empty Wing/Hall and lets frontmatter set them if present.
|
||||||
|
func TestExtract_WikiRootFileIsKnowledgeNotHall(t *testing.T) {
|
||||||
|
content := []byte("# Index\n\n- [[foo]]\n")
|
||||||
|
ent, _, ok := Extract("wiki/index.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "index", ent.Slug)
|
||||||
|
assert.Equal(t, "knowledge", ent.Type)
|
||||||
|
assert.Empty(t, ent.Wing)
|
||||||
|
assert.Empty(t, ent.Hall)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_TierFromFrontmatter(t *testing.T) {
|
||||||
|
content := []byte(`---
|
||||||
|
tier: knowledge
|
||||||
|
topic: postgres-roles
|
||||||
|
title: Least-privilege migration trap
|
||||||
|
---
|
||||||
|
# body
|
||||||
|
`)
|
||||||
|
ent, _, ok := Extract("knowledge/some-lesson.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "knowledge", ent.Tier)
|
||||||
|
assert.Equal(t, "postgres-roles", ent.Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_TierInferredFromPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"knowledge/foo.md", "knowledge"},
|
||||||
|
{"wiki/sources/x.md", "note"},
|
||||||
|
{"wiki/concepts/x.md", "note"},
|
||||||
|
{"wiki/x.md", "note"},
|
||||||
|
{"inbox/clips/x.md", "inbox"},
|
||||||
|
{"notes/x.md", "note"},
|
||||||
|
{"raw/x.md", "inbox"},
|
||||||
|
{"sessions/x.md", "inbox"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
ent, _, ok := Extract(tc.path, []byte("# x\n"))
|
||||||
|
require.True(t, ok, tc.path)
|
||||||
|
assert.Equal(t, tc.want, ent.Tier, tc.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_FrontmatterTierBeatsPathInference(t *testing.T) {
|
||||||
|
// A clip explicitly promoted via frontmatter wins over the path's
|
||||||
|
// inbox inference. Catches the case where a file has been moved
|
||||||
|
// to a new location but frontmatter hasn't been updated.
|
||||||
|
content := []byte("---\ntier: knowledge\n---\n# x\n")
|
||||||
|
ent, _, ok := Extract("inbox/clips/x.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "knowledge", ent.Tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtract_WikiRootFileWithFrontmatterWingHall(t *testing.T) {
|
||||||
|
content := []byte(`---
|
||||||
|
wing: homelab
|
||||||
|
hall: facts
|
||||||
|
---
|
||||||
|
# Some root note
|
||||||
|
`)
|
||||||
|
ent, _, ok := Extract("wiki/some-note.md", content)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "knowledge", ent.Type)
|
||||||
|
assert.Equal(t, "homelab", ent.Wing)
|
||||||
|
assert.Equal(t, "facts", ent.Hall)
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// Package graphstore stores the brain knowledge graph (entities +
|
||||||
|
// directed edges) in PostgreSQL on the shared postgres18 instance,
|
||||||
|
// alongside the pgvector embeddings in [vectorstore].
|
||||||
|
//
|
||||||
|
// Schema (created idempotently by Init):
|
||||||
|
//
|
||||||
|
// brain_entities(slug PK, type, wing, hall, doc_path, title, updated_at)
|
||||||
|
// brain_edges(id PK, src_slug FK, dst_slug, edge_type, src_doc, src_line,
|
||||||
|
// weight, updated_at)
|
||||||
|
//
|
||||||
|
// Edges fan-out from a source document; calling [PGStore.ReplaceEdgesForDoc]
|
||||||
|
// replaces every edge previously emitted from that document so re-ingest is
|
||||||
|
// idempotent without bookkeeping.
|
||||||
|
//
|
||||||
|
// All slug strings are stored verbatim — callers are expected to canonicalise
|
||||||
|
// before persisting. Dst slugs may reference entities that don't yet exist
|
||||||
|
// (dangling edges); resolution is deferred to query time so ingestion order
|
||||||
|
// doesn't matter.
|
||||||
|
package graphstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PGStore is the postgres-backed brain knowledge-graph store. Construct
|
||||||
|
// with New + call Init once to create tables and indexes. Use Close to
|
||||||
|
// release the pool.
|
||||||
|
type PGStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New opens a pgxpool against dsn and pings to verify connectivity. The
|
||||||
|
// caller owns the resulting PGStore and must invoke Close.
|
||||||
|
func New(ctx context.Context, dsn string) (*PGStore, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pgxpool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("ping: %w", err)
|
||||||
|
}
|
||||||
|
return &PGStore{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying connection pool.
|
||||||
|
func (s *PGStore) Close() {
|
||||||
|
if s.pool != nil {
|
||||||
|
s.pool.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init creates brain_entities + brain_edges tables and their indexes if
|
||||||
|
// they don't yet exist. Safe to call on every startup. No-op when the
|
||||||
|
// schema already matches.
|
||||||
|
func (s *PGStore) Init(ctx context.Context) error {
|
||||||
|
const ddl = `
|
||||||
|
CREATE TABLE IF NOT EXISTS brain_entities (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL DEFAULT 'knowledge',
|
||||||
|
wing TEXT NOT NULL DEFAULT '',
|
||||||
|
hall TEXT NOT NULL DEFAULT '',
|
||||||
|
doc_path TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
tier TEXT NOT NULL DEFAULT '',
|
||||||
|
topic TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
-- Idempotent migration for clusters created before the DIKW tier
|
||||||
|
-- redesign (infra#72). ADD COLUMN IF NOT EXISTS is safe across
|
||||||
|
-- repeated startups.
|
||||||
|
ALTER TABLE brain_entities
|
||||||
|
ADD COLUMN IF NOT EXISTS tier TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS topic TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_entities_wing_idx
|
||||||
|
ON brain_entities (wing) WHERE wing <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_entities_type_idx
|
||||||
|
ON brain_entities (type);
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_entities_tier_idx
|
||||||
|
ON brain_entities (tier) WHERE tier <> '';
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_entities_topic_idx
|
||||||
|
ON brain_entities (topic) WHERE topic <> '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS brain_edges (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
src_slug TEXT NOT NULL,
|
||||||
|
dst_slug TEXT NOT NULL,
|
||||||
|
edge_type TEXT NOT NULL DEFAULT 'wikilink',
|
||||||
|
src_doc TEXT NOT NULL,
|
||||||
|
src_line INTEGER NOT NULL DEFAULT 0,
|
||||||
|
weight REAL NOT NULL DEFAULT 1.0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_edges_src_idx
|
||||||
|
ON brain_edges (src_slug, edge_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_edges_dst_idx
|
||||||
|
ON brain_edges (dst_slug, edge_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS brain_edges_src_doc_idx
|
||||||
|
ON brain_edges (src_doc);
|
||||||
|
`
|
||||||
|
_, err := s.pool.Exec(ctx, ddl)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertEntity inserts or updates one entity by slug.
|
||||||
|
func (s *PGStore) UpsertEntity(ctx context.Context, e graph.Entity) error {
|
||||||
|
if e.Slug == "" {
|
||||||
|
return errors.New("entity slug is required")
|
||||||
|
}
|
||||||
|
if e.Type == "" {
|
||||||
|
e.Type = "knowledge"
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO brain_entities (slug, type, wing, hall, doc_path, title, tier, topic, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||||
|
ON CONFLICT (slug) DO UPDATE
|
||||||
|
SET type = EXCLUDED.type,
|
||||||
|
wing = EXCLUDED.wing,
|
||||||
|
hall = EXCLUDED.hall,
|
||||||
|
doc_path = EXCLUDED.doc_path,
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
tier = EXCLUDED.tier,
|
||||||
|
topic = EXCLUDED.topic,
|
||||||
|
updated_at = now()
|
||||||
|
`, e.Slug, e.Type, e.Wing, e.Hall, e.DocPath, e.Title, e.Tier, e.Topic)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert entity %q: %w", e.Slug, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceEdgesForDoc deletes every edge previously emitted from docPath
|
||||||
|
// and inserts the new set in one transaction. Caller should pass the
|
||||||
|
// complete edge set for the doc — partial updates are not supported.
|
||||||
|
func (s *PGStore) ReplaceEdgesForDoc(ctx context.Context, docPath string, edges []graph.Edge) error {
|
||||||
|
if docPath == "" {
|
||||||
|
return errors.New("doc path is required")
|
||||||
|
}
|
||||||
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM brain_edges WHERE src_doc = $1`, docPath); err != nil {
|
||||||
|
return fmt.Errorf("delete prior edges for %q: %w", docPath, err)
|
||||||
|
}
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.SrcSlug == "" || e.DstSlug == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO brain_edges (src_slug, dst_slug, edge_type, src_doc, src_line, weight)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 1.0)
|
||||||
|
`, e.SrcSlug, e.DstSlug, e.EdgeType, e.SrcDoc, e.SrcLine); err != nil {
|
||||||
|
return fmt.Errorf("insert edge %s->%s: %w", e.SrcSlug, e.DstSlug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByDoc removes the entity at docPath and every edge it sourced.
|
||||||
|
// Use when a wiki page is deleted on disk.
|
||||||
|
func (s *PGStore) DeleteByDoc(ctx context.Context, docPath string) error {
|
||||||
|
if docPath == "" {
|
||||||
|
return errors.New("doc path is required")
|
||||||
|
}
|
||||||
|
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM brain_edges WHERE src_doc = $1`, docPath); err != nil {
|
||||||
|
return fmt.Errorf("delete edges: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM brain_entities WHERE doc_path = $1`, docPath); err != nil {
|
||||||
|
return fmt.Errorf("delete entity: %w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbor is one row in a Neighbors / Subgraph response.
|
||||||
|
type Neighbor struct {
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
|
Wing string
|
||||||
|
Hall string
|
||||||
|
DocPath string
|
||||||
|
Title string
|
||||||
|
EdgeType string
|
||||||
|
Distance int // hop count from origin; 1 for direct neighbors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbors returns the direct (1-hop) outgoing neighbours of slug.
|
||||||
|
// edgeType filters by relationship kind; "" returns all kinds.
|
||||||
|
// limit defaults to 25 when <= 0.
|
||||||
|
func (s *PGStore) Neighbors(ctx context.Context, slug, edgeType string, limit int) ([]Neighbor, error) {
|
||||||
|
if slug == "" {
|
||||||
|
return nil, errors.New("slug is required")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 25
|
||||||
|
}
|
||||||
|
q := `
|
||||||
|
SELECT e.dst_slug, COALESCE(t.type,''), COALESCE(t.wing,''), COALESCE(t.hall,''),
|
||||||
|
COALESCE(t.doc_path,''), COALESCE(t.title,''), e.edge_type, 1
|
||||||
|
FROM brain_edges e
|
||||||
|
LEFT JOIN brain_entities t ON t.slug = e.dst_slug
|
||||||
|
WHERE e.src_slug = $1
|
||||||
|
AND ($2 = '' OR e.edge_type = $2)
|
||||||
|
ORDER BY e.updated_at DESC
|
||||||
|
LIMIT $3
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, q, slug, edgeType, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query neighbors: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanNeighbors(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subgraph returns every distinct slug reachable from origin within
|
||||||
|
// depth outgoing hops, annotated with the shortest hop distance. The
|
||||||
|
// origin itself is omitted. depth defaults to 2 when <= 0; values
|
||||||
|
// above 6 are clamped to 6 to bound traversal cost.
|
||||||
|
func (s *PGStore) Subgraph(ctx context.Context, origin string, depth int) ([]Neighbor, error) {
|
||||||
|
if origin == "" {
|
||||||
|
return nil, errors.New("origin slug is required")
|
||||||
|
}
|
||||||
|
if depth <= 0 {
|
||||||
|
depth = 2
|
||||||
|
}
|
||||||
|
if depth > 6 {
|
||||||
|
depth = 6
|
||||||
|
}
|
||||||
|
q := `
|
||||||
|
WITH RECURSIVE walk(slug, edge_type, distance) AS (
|
||||||
|
SELECT e.dst_slug, e.edge_type, 1
|
||||||
|
FROM brain_edges e
|
||||||
|
WHERE e.src_slug = $1
|
||||||
|
UNION
|
||||||
|
SELECT e.dst_slug, e.edge_type, w.distance + 1
|
||||||
|
FROM walk w
|
||||||
|
JOIN brain_edges e ON e.src_slug = w.slug
|
||||||
|
WHERE w.distance < $2
|
||||||
|
)
|
||||||
|
SELECT w.slug, COALESCE(t.type,''), COALESCE(t.wing,''), COALESCE(t.hall,''),
|
||||||
|
COALESCE(t.doc_path,''), COALESCE(t.title,''), w.edge_type, MIN(w.distance)
|
||||||
|
FROM walk w
|
||||||
|
LEFT JOIN brain_entities t ON t.slug = w.slug
|
||||||
|
WHERE w.slug <> $1
|
||||||
|
GROUP BY w.slug, t.type, t.wing, t.hall, t.doc_path, t.title, w.edge_type
|
||||||
|
ORDER BY MIN(w.distance), w.slug
|
||||||
|
`
|
||||||
|
rows, err := s.pool.Query(ctx, q, origin, depth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query subgraph: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanNeighbors(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathStep is one hop in a Path response.
|
||||||
|
type PathStep struct {
|
||||||
|
FromSlug string
|
||||||
|
ToSlug string
|
||||||
|
EdgeType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the shortest directed path from src to dst within
|
||||||
|
// maxDepth hops, as an ordered list of edges. Empty slice means no
|
||||||
|
// path exists. maxDepth defaults to 4 when <= 0; values above 8 are
|
||||||
|
// clamped to 8.
|
||||||
|
func (s *PGStore) Path(ctx context.Context, src, dst string, maxDepth int) ([]PathStep, error) {
|
||||||
|
if src == "" || dst == "" {
|
||||||
|
return nil, errors.New("src and dst are required")
|
||||||
|
}
|
||||||
|
if maxDepth <= 0 {
|
||||||
|
maxDepth = 4
|
||||||
|
}
|
||||||
|
if maxDepth > 8 {
|
||||||
|
maxDepth = 8
|
||||||
|
}
|
||||||
|
q := `
|
||||||
|
WITH RECURSIVE walk(cur, path_slugs, path_edges, distance) AS (
|
||||||
|
SELECT e.dst_slug,
|
||||||
|
ARRAY[e.src_slug, e.dst_slug]::TEXT[],
|
||||||
|
ARRAY[e.edge_type]::TEXT[],
|
||||||
|
1
|
||||||
|
FROM brain_edges e
|
||||||
|
WHERE e.src_slug = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.dst_slug,
|
||||||
|
w.path_slugs || e.dst_slug,
|
||||||
|
w.path_edges || e.edge_type,
|
||||||
|
w.distance + 1
|
||||||
|
FROM walk w
|
||||||
|
JOIN brain_edges e ON e.src_slug = w.cur
|
||||||
|
WHERE w.distance < $3
|
||||||
|
AND NOT (e.dst_slug = ANY(w.path_slugs))
|
||||||
|
)
|
||||||
|
SELECT path_slugs, path_edges
|
||||||
|
FROM walk
|
||||||
|
WHERE cur = $2
|
||||||
|
ORDER BY distance ASC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
row := s.pool.QueryRow(ctx, q, src, dst, maxDepth)
|
||||||
|
var (
|
||||||
|
slugs []string
|
||||||
|
kinds []string
|
||||||
|
)
|
||||||
|
if err := row.Scan(&slugs, &kinds); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("scan path: %w", err)
|
||||||
|
}
|
||||||
|
if len(slugs) < 2 || len(kinds) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
steps := make([]PathStep, 0, len(kinds))
|
||||||
|
for i := 0; i < len(kinds) && i+1 < len(slugs); i++ {
|
||||||
|
steps = append(steps, PathStep{
|
||||||
|
FromSlug: slugs[i],
|
||||||
|
ToSlug: slugs[i+1],
|
||||||
|
EdgeType: kinds[i],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return steps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountEdges is a debug helper — returns the total edges currently stored.
|
||||||
|
// Used by tests and by the volume-gate diagnostic.
|
||||||
|
func (s *PGStore) CountEdges(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM brain_edges`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanNeighbors(rows pgx.Rows) ([]Neighbor, error) {
|
||||||
|
var out []Neighbor
|
||||||
|
for rows.Next() {
|
||||||
|
var n Neighbor
|
||||||
|
if err := rows.Scan(
|
||||||
|
&n.Slug, &n.Type, &n.Wing, &n.Hall,
|
||||||
|
&n.DocPath, &n.Title, &n.EdgeType, &n.Distance,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user