Compare commits
135
Commits
f8cf27e5de
...
v0.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6da93332d | ||
|
|
f21be1aef6 | ||
|
|
3c70b6f367 | ||
|
|
b3b555fa23 | ||
|
|
e9805c7eb6 | ||
|
|
3d5af85713 | ||
|
|
c6341f020a | ||
|
|
e157311cd8 | ||
|
|
a5f502eb9c | ||
|
|
7ed2a7d3b4 | ||
|
|
674858fe5e | ||
|
|
9a37881441 | ||
|
|
f324f04944 | ||
|
|
cdafdf6e0f | ||
|
|
b340f86a45 | ||
|
|
f450a6c84f | ||
|
|
5a5d938e8e | ||
|
|
cc86a74dc5 | ||
|
|
6e449b464c | ||
|
|
868fe28c96 | ||
|
|
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 |
@@ -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": {
|
||||
"url": "http://localhost:3200/mcp",
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+222
-31
@@ -32,6 +32,14 @@ and climate/sustainability tech.
|
||||
|
||||
These rules apply to every task across every project, regardless of harness.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
@@ -45,14 +53,124 @@ 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
|
||||
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.
|
||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||
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
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||
collisions between parallel sessions: two were not branching problems at all — a
|
||||
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.
|
||||
|
||||
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||
PR would buy a review of a state that was never deployed.
|
||||
|
||||
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||
green" is not the same claim.
|
||||
|
||||
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||
|
||||
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||
lane silently does not apply.
|
||||
|
||||
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.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -64,7 +182,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| 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 | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
@@ -73,7 +191,12 @@ 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
|
||||
- **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
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
@@ -83,6 +206,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <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
|
||||
|
||||
Three machines on Tailscale:
|
||||
@@ -91,10 +234,10 @@ Three machines on Tailscale:
|
||||
|---------|------|-----------|
|
||||
| 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 |
|
||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||
|
||||
- **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
|
||||
|
||||
## Project landscape
|
||||
@@ -162,9 +305,15 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||
were not.)
|
||||
- **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
|
||||
@@ -190,10 +339,11 @@ When working on a project tagged with a client name:
|
||||
|
||||
## 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
|
||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||
re-add adapters for them:
|
||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||
- 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).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
@@ -205,34 +355,75 @@ Canonical sources of truth:
|
||||
- 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`
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.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:
|
||||
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`.
|
||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||
- `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`
|
||||
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.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## 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
|
||||
- **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
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -1,7 +1,6 @@
|
||||
# Supervisor MCP server
|
||||
SUPERVISOR_PORT=3200
|
||||
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
||||
SUPERVISOR_MODELS_FILE=./config/models.yaml
|
||||
|
||||
# LiteLLM gateway (iguana)
|
||||
LITELLM_BASE_URL=http://iguana:4000
|
||||
|
||||
+42
-73
@@ -1,6 +1,6 @@
|
||||
name: cd
|
||||
|
||||
on:
|
||||
"on":
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
@@ -13,9 +13,8 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
|
||||
environment: staging
|
||||
env:
|
||||
INGESTION_IMAGE: gitea.d-ma.be/mathias/ingestion
|
||||
ROUTING_IMAGE: gitea.d-ma.be/mathias/routing
|
||||
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
|
||||
INGESTION_IMAGE: git.d-ma.be/mathias/ingestion
|
||||
INFRA_REPO: git@git.d-ma.be:mathias/infra.git
|
||||
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -41,58 +40,60 @@ jobs:
|
||||
|
||||
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
|
||||
run: |
|
||||
set -e
|
||||
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||
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
|
||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.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
|
||||
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||
# 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;
|
||||
# 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
|
||||
|
||||
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"
|
||||
|
||||
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.name "CD Bot"
|
||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml" \
|
||||
"k3s/apps/routing/deployment.yaml"
|
||||
git commit -m "chore(deploy): ingestion+routing → ${IMAGE_TAG}"
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||
git push
|
||||
|
||||
echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}"
|
||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||
# Idempotent: `git commit` exits 1 on an empty index, so a run that
|
||||
# finds the bump already applied failed the whole job and rang the
|
||||
# CI-red alarm for a deploy that was fine (hyperguild#118). Two
|
||||
# sources of a no-op run: a manual re-run, and the tag-push CI run
|
||||
# that used to shadow the branch-push one for the same sha.
|
||||
if git diff --cached --quiet; then
|
||||
echo "Infra already at ${IMAGE_TAG} — nothing to commit"
|
||||
else
|
||||
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||
git push
|
||||
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
- name: Trigger Flux reconcile (immediate)
|
||||
run: |
|
||||
@@ -103,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Wait for Flux to apply new ingestion image
|
||||
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
|
||||
CURRENT=$(kubectl get deploy ingestion -n supervisor \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
|
||||
@@ -132,35 +133,3 @@ jobs:
|
||||
kubectl describe pods -n supervisor -l app=ingestion | tail -40
|
||||
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,9 +1,14 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
"on":
|
||||
push:
|
||||
# Branch only. A `v*` tag here points at a commit main already tested, so
|
||||
# the tag push re-ran the whole gate and — because cd.yml keys off "CI
|
||||
# succeeded" — triggered a SECOND full image build and deploy for the same
|
||||
# sha. That second deploy then died on "nothing to commit" (hyperguild#118).
|
||||
# Nothing consumes a tag-triggered run: there is no release workflow, and
|
||||
# cd.yml reads github.sha, not the tag.
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
||||
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": {
|
||||
"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.
|
||||
|
||||
0. **Pre-task ritual — before ANY implementation (non-negotiable).** Run this before writing a single line:
|
||||
- **Query the brain** (`brain_query`) for the domain + symptom. If the result changes your approach, surface it before acting. 5 seconds beats 5 hours.
|
||||
- **Load the relevant skill** — see trigger table in *Engineering Skills* below.
|
||||
- **Write the failing test first.** Name the test before the function. If the target is untestable (e.g. `main()` wiring), extract the logic into a testable function first. No implementation without a red test.
|
||||
- **State the observable success criterion** — what specific behavior, output, or passing test proves this is done?
|
||||
|
||||
**TDD is non-negotiable.** "Tests pass" is not proof of correctness — only proof the tests ran. Write tests that would catch the bug before writing code that fixes it.
|
||||
|
||||
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
|
||||
Think before coding; if the problem is unclear, ask or state assumptions before acting.
|
||||
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
|
||||
@@ -40,14 +48,124 @@ 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
|
||||
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.
|
||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||
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
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||
collisions between parallel sessions: two were not branching problems at all — a
|
||||
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.
|
||||
|
||||
**Exception — a GitOps repo where a branch cannot be reconciled.** In a repo whose
|
||||
deployment watches one branch (`mathias/infra`: Flux watches `main`), a branch is
|
||||
invisible to the thing that applies it. A `risk/medium` change to `alerts.yaml` or
|
||||
`litellm-config.yaml` can be linted on a branch but never *exercised* there, so a
|
||||
PR would buy a review of a state that was never deployed.
|
||||
|
||||
There, `risk/medium`+ work stays on `main` and instead requires a **recorded
|
||||
adversarial review pass before the push** — `skills:pr-gatekeeper` or equivalent,
|
||||
reviewing the staged diff. **The reviewer's verdict goes in the issue, not a PR
|
||||
thread.** No verdict recorded means the lane was not used, and saying "the gate was
|
||||
green" is not the same claim.
|
||||
|
||||
Be honest about what this is: a recorded *independent* pass, not four-eyes in the
|
||||
sense `assessor-loop` §4.1 means for a regulated organisation, because the reviewer
|
||||
is an agent. It is more than the repo had and less than the model it mirrors; do not
|
||||
describe it as the stronger property. (infra#470, decided 2026-09-29.)
|
||||
|
||||
This lane depends on the `risk/` label being right. A label that triggers a heavier
|
||||
lane is load-bearing, not cosmetic — if work that belongs in it is unlabelled, the
|
||||
lane silently does not apply.
|
||||
|
||||
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.
|
||||
|
||||
7. **Finish what's closable — only involve me for a genuine call.** If a
|
||||
tracker issue, PR, or checklist item is demonstrably resolved — the fix
|
||||
landed, the state changed, you verified it live — close/merge/check it off
|
||||
in the same turn. Don't leave it open "for the record" or park it waiting
|
||||
on me to confirm something that already happened; that's bookkeeping, not
|
||||
a decision. Reserve asking me (`AskUserQuestion`, a `DECISION NEEDED`
|
||||
comment, or otherwise pausing) for when it's genuinely mine to call:
|
||||
irreversible or prod/host-impacting actions, a real judgment call with no
|
||||
clearly-better option, ambiguous intent, or anything secret-writes/
|
||||
destructive-git already gate. Before claiming something is closable,
|
||||
verify it live (re-check the issue, re-run the command) rather than
|
||||
trusting your own earlier summary (brain: `filed-issue-not-same-as-fixed`).
|
||||
|
||||
8. **Control code needs an adversary, not a checklist.** Code whose job is to
|
||||
refuse, deny, gate or contain is not adequately verified by a green gate.
|
||||
Rule 5 makes CI the quality gate and that holds for ordinary work — but a
|
||||
passing suite only asserts what its author already thought of, and for a
|
||||
control the author is precisely the person whose imagination just failed.
|
||||
Measured on `dispatch#75`: five review rounds, five real escapes verified by
|
||||
execution, and **CI was green on every one of them** while `git rm
|
||||
././.dispatch-allow` deleted the file the control existed to protect. So
|
||||
route this work through a review with authority to block, instructed to
|
||||
verify by execution rather than by reading the implementation. Expect to be
|
||||
blocked more than once; that is the control working, not the reviewer being
|
||||
difficult. (brain: `a-green-gate-is-not-evidence-for-control-code`; the
|
||||
practices live in the `pr-gatekeeper` agent.)
|
||||
|
||||
9. **A test's name must not claim more than its assertions prove.** Name a test
|
||||
after the mechanism it observes, not the goal it serves.
|
||||
`DoesNotInheritEnv` is honest; `IsContained` is a claim the test cannot
|
||||
support. This is not pedantry about naming — when name and assertion
|
||||
diverge, the name wins in the reader's head and the gap becomes invisible,
|
||||
which is what lets the defect survive review. Found three times in one PR,
|
||||
twice in tests written to fix the previous instance of it; each passed while
|
||||
the thing it was named for was exploitable. If you cannot name the mechanism,
|
||||
you do not yet know what the test observes.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -59,7 +177,7 @@ These rules apply to every task across every project, regardless of harness.
|
||||
| Containers | Docker Compose (dev), k3s (prod) | — | — |
|
||||
| DB | PostgreSQL + sqlc | SQLite | — |
|
||||
| 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 | — | — |
|
||||
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
|
||||
|
||||
@@ -68,7 +186,12 @@ 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
|
||||
- **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
|
||||
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
|
||||
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
|
||||
@@ -78,6 +201,26 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
|
||||
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
|
||||
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
|
||||
|
||||
## Secret handling (every harness, every command)
|
||||
|
||||
Tool output is persisted: terminal → `~/.claude/projects` transcripts →
|
||||
claudewatcher → brain/wiki → gitea history. A secret printed once is
|
||||
searchable forever, and clearing it means rotating the key. So:
|
||||
|
||||
1. **Never print, echo, log, or transform a secret to inspect it.** No
|
||||
`base64`/`xxd`/`cat` of a key, and never pipe a secret through a transform
|
||||
to defeat `op run`'s output masking (it masks raw values; base64 hides them
|
||||
from the mask — that exact trick leaked a key on 2026-06-11).
|
||||
2. **Secrets stay in the subprocess.** Reference them only as env vars consumed
|
||||
*inside* `op run --env-file ~/.op-env -- <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
|
||||
|
||||
Three machines on Tailscale:
|
||||
@@ -86,10 +229,10 @@ Three machines on Tailscale:
|
||||
|---------|------|-----------|
|
||||
| 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 |
|
||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||
|
||||
- **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
|
||||
|
||||
## Project landscape
|
||||
@@ -157,9 +300,15 @@ entries that age well are about *why*, *how to avoid*, and *what to do when*.
|
||||
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
|
||||
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
|
||||
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
|
||||
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
| **Browser / human inspection** | `https://git.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
|
||||
|
||||
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
|
||||
- **Scoping — the brain holds no confidential client content.** Nothing on the read path
|
||||
filters by client: every token holder can read every wing, and `wing` on `brain_query`
|
||||
is an optional argument, not an access control. Client confidentiality is enforced only
|
||||
at write time (`capture`'s I1 gate refuses internal/confidential). So never `brain_write`
|
||||
client material; generalise it first or keep it in the client's own repo. (hyperguild#144,
|
||||
decided 2026-10-01. This line previously claimed client projects were filtered; they
|
||||
were not.)
|
||||
- **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
|
||||
@@ -185,10 +334,11 @@ When working on a project tagged with a client name:
|
||||
|
||||
## 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
|
||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||
re-add adapters for them:
|
||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||
- 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).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
@@ -200,34 +350,75 @@ Canonical sources of truth:
|
||||
- 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`
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.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:
|
||||
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`.
|
||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||
- `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`
|
||||
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.
|
||||
Behavior rules in this file and per-project rules in `PROJECT.md` apply on every
|
||||
host and every harness.
|
||||
|
||||
**Where a project rule contradicts a rule here, the project rule governs — and it
|
||||
must state its reason.** Both halves matter: the project file wins because it knows
|
||||
something this file cannot (infra's Flux constraint is real and not visible from
|
||||
here), and it must say why because a bare override is indistinguishable from drift.
|
||||
|
||||
This replaces the previous wording, which said rules here and in `PROJECT.md` both
|
||||
apply *"unconditionally"*. That was not a tiebreak but a contradiction, and it went
|
||||
unnoticed until rule 5 and infra's "never create feature branches" had to be obeyed
|
||||
at the same time (infra#470). A standard can hold two individually sensible clauses
|
||||
that are jointly unsatisfiable, and survive for as long as everyone reads it
|
||||
charitably — the same failure the Inception Sprint Oath's G6 had.
|
||||
|
||||
## 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
|
||||
- **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
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
**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).
|
||||
@@ -1,2 +1 @@
|
||||
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
||||
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 INGEST_SVC_URL=http://localhost:3300 go run ./cmd/supervisor/
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
```
|
||||
Your Claude Code session (in any project)
|
||||
│
|
||||
│ MCP over HTTP (Tailscale)
|
||||
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
|
||||
├──▶ routing :3210 (NodePort 30310 on koala) — Mode 2 only: review, debug, retrospective, trainer
|
||||
├──▶ routing :3210 (NodePort 30310 on koala) — review, debug, retrospective, trainer
|
||||
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
|
||||
│
|
||||
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
|
||||
@@ -20,34 +42,113 @@ Your Claude Code session (in any project)
|
||||
▼
|
||||
brain/
|
||||
├── sessions/ — JSONL log, one file per session_id
|
||||
├── wiki/ — searchable knowledge (full-text)
|
||||
│ ├── concepts/
|
||||
│ ├── entities/
|
||||
│ └── sources/
|
||||
├── raw/ — retrospective output, staged for review
|
||||
└── training-data/ — SFT/DPO/RL data (Phase 2)
|
||||
├── wiki/ — searchable knowledge (wing/hall layout)
|
||||
│ ├── homelab/
|
||||
│ ├── claude-sessions/
|
||||
│ └── ...
|
||||
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
## Ingest revises, it does not overwrite
|
||||
|
||||
Re-ingesting a source **merges into the page already on disk** rather than
|
||||
replacing it. Previously the write was unconditional, so a second ingest of the
|
||||
same source discarded everything accumulated since — including hand-written
|
||||
sections.
|
||||
|
||||
The merge is deliberately asymmetric in the existing page's favour:
|
||||
|
||||
| On the page | In the fresh extraction | Result |
|
||||
|---|---|---|
|
||||
| a section | the same section | **the on-disk version wins** |
|
||||
| — | a new section | added |
|
||||
| a bullet list (`Sources`, `Key Claims`, …) | more bullets | unioned, no duplicates |
|
||||
| an append section (`Open Questions`, `Updates`, …) | more lines | only genuinely new lines appended |
|
||||
| frontmatter | frontmatter | **the on-disk frontmatter wins** |
|
||||
|
||||
So an extraction can add to a note but never silently rewrite what a human put
|
||||
there, which is what makes it safe to run on every ingest rather than behind a
|
||||
review gate.
|
||||
|
||||
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
||||
does not commit noise that hides real edits.
|
||||
|
||||
Two fields are **generator-owned** and do get refreshed on a revise:
|
||||
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
||||
an explicit allowlist, so a field nobody has thought about stays human-owned.
|
||||
|
||||
### Disagreement is reported, not resolved
|
||||
|
||||
When the fresh extraction produces a *different version* of a section that
|
||||
already exists, the on-disk version still wins — but the run now says so:
|
||||
|
||||
```
|
||||
wiki/sources/shape-up.md: source now disagrees on section "Summary" — on-disk version kept
|
||||
```
|
||||
|
||||
That line lands in `log.md` with the rest of the run's warnings. Without it, "the
|
||||
source is unchanged" and "the source changed its mind and we ignored it" look
|
||||
identical from the outside.
|
||||
|
||||
This is deliberately **textual** disagreement, not semantic contradiction: cheap,
|
||||
deterministic, no model on the write path. Deciding whether a disagreement is a
|
||||
genuine contradiction stays with the Judge-gated `brain-enrich` pass, which is
|
||||
approval-gated by design. Detection here, adjudication there.
|
||||
|
||||
## Re-ingesting an unchanged source is skipped
|
||||
|
||||
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
||||
excluded — on the source page. A later ingest of the same source compares it
|
||||
first and skips extraction entirely when it matches, reporting the skip rather
|
||||
than doing it silently.
|
||||
|
||||
Body-only is deliberate: a re-crawl that stamps a new fetch date has not changed
|
||||
the source, and treating that as drift would re-extract the corpus every time a
|
||||
crawler ran. A missing or unreadable hash always means "extract" — a missed skip
|
||||
costs one extraction, a wrong skip loses an update.
|
||||
|
||||
## Phase 1 tools (available now)
|
||||
|
||||
| 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 |
|
||||
| `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_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) |
|
||||
|
||||
## Start the servers
|
||||
|
||||
```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
|
||||
```
|
||||
> **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).
|
||||
|
||||
## Connect a project
|
||||
|
||||
@@ -56,9 +157,9 @@ Create `.mcp.json` in your project root:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"supervisor": {
|
||||
"routing": {
|
||||
"type": "http",
|
||||
"url": "http://koala:30320/mcp"
|
||||
"url": "http://koala:30310/mcp"
|
||||
},
|
||||
"brain": {
|
||||
"type": "http",
|
||||
@@ -68,65 +169,77 @@ 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`,
|
||||
`review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`).
|
||||
- **`routing`** at `koala:30310` — skill workers (`review`, `debug`, `retrospective`, `trainer`).
|
||||
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_ingest`, `brain_ingest_raw`) and `session_log`. Hosted by the ingestion
|
||||
service directly, no separate pod.
|
||||
`brain_ingest`, `brain_ingest_raw`, `brain_answer`, `brain_classify`) and `session_log`.
|
||||
|
||||
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.
|
||||
|
||||
## A typical TDD session
|
||||
## A typical session
|
||||
|
||||
```
|
||||
1. Call tdd_red → spec in, failing test file out
|
||||
2. Call tdd_green → test path in, implementation out
|
||||
3. Call tdd_refactor → impl + test in, cleaned code out
|
||||
4. Call session_log → log each phase result
|
||||
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
|
||||
1. Call review → brain context injected + local model review → findings
|
||||
2. Call session_log → log each phase result
|
||||
3. Call retrospective → extracts learnings → brain
|
||||
4. Future sessions: call brain_query / brain_answer to retrieve relevant context
|
||||
```
|
||||
|
||||
## Tier detection
|
||||
|
||||
The supervisor probes connectivity at call time:
|
||||
The routing pod probes connectivity at call time:
|
||||
|
||||
| Tier | Label | Condition |
|
||||
|------|-------|-----------|
|
||||
|------|-------|-----------|
|
||||
| 1 | full-online | Can reach api.anthropic.com |
|
||||
| 2 | lan-only | Can reach LiteLLM but not Anthropic |
|
||||
| 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
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
|----------|---------|---------|
|
||||
| `INGEST_BRAIN_DIR` | `../brain` | Brain directory for ingestion server |
|
||||
| `INGEST_PORT` | `3300` | Ingestion server port |
|
||||
| `SUPERVISOR_CONFIG_DIR` | `./config/supervisor` | Skill discipline files |
|
||||
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
|
||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
|
||||
| `INGEST_BASE_URL` | `http://localhost:3300` | Routing pod → brain |
|
||||
| `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_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) |
|
||||
| `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_ROUTE_LOCAL_FLOOR` | `0.90` | At/above pass rate, route to fast model |
|
||||
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below pass rate, route to thinking model. Between CEIL and FLOOR is the sample band. |
|
||||
| `HYPERGUILD_ROUTE_LOCAL_FLOOR` | `0.90` | Fast model threshold |
|
||||
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Thinking model threshold |
|
||||
| `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
|
||||
- `debug` skill — hypothesis-driven debugging sessions
|
||||
- `spec` skill — generates specs from conversations
|
||||
- `trainer` — extracts SFT/DPO pairs from session logs for fine-tuning
|
||||
See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||
|
||||
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||
- **#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
|
||||
|
||||
+74
-38
@@ -17,13 +17,11 @@ tasks:
|
||||
cmds: [bash scripts/context-sync.sh claude]
|
||||
context:sync:agents:
|
||||
cmds: [bash scripts/context-sync.sh agents]
|
||||
context:sync:cursor:
|
||||
cmds: [bash scripts/context-sync.sh cursor]
|
||||
|
||||
# ── Development ────────────────────────────────────────────────────────────
|
||||
|
||||
start:
|
||||
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
|
||||
desc: Start ingestion (requires goreman — go install github.com/mattn/goreman@latest)
|
||||
cmds:
|
||||
- goreman start
|
||||
|
||||
@@ -34,11 +32,6 @@ tasks:
|
||||
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
||||
- echo "hyperguild stopped"
|
||||
|
||||
supervisor:dev:
|
||||
desc: Run supervisor MCP server (development)
|
||||
cmds:
|
||||
- go run ./cmd/supervisor
|
||||
|
||||
hyperguild:dev:
|
||||
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
||||
cmds:
|
||||
@@ -69,14 +62,9 @@ tasks:
|
||||
build:
|
||||
desc: Build all binaries
|
||||
cmds:
|
||||
- task: supervisor:build
|
||||
- task: hyperguild:build
|
||||
- task: ingestion:build
|
||||
|
||||
supervisor:build:
|
||||
desc: Build supervisor binary
|
||||
cmds:
|
||||
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
||||
|
||||
ingestion:build:
|
||||
desc: Build ingestion server binary
|
||||
dir: ingestion
|
||||
@@ -86,29 +74,90 @@ tasks:
|
||||
# ── Quality ────────────────────────────────────────────────────────────────
|
||||
|
||||
check:
|
||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||
desc: Run all checks (context freshness + lint + test + vet + build) across all modules
|
||||
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: |
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
||||
if [ -n "$drift" ]; then
|
||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||
echo "$drift" >&2
|
||||
echo "" >&2
|
||||
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
|
||||
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||
exit 1
|
||||
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
|
||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||
echo "$drift" >&2
|
||||
echo "" >&2
|
||||
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
|
||||
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ context: canonical and adapters are in sync"
|
||||
fi
|
||||
echo "✓ context: canonical and adapters are in sync"
|
||||
- task: lint
|
||||
- task: test
|
||||
- task: vet
|
||||
# `build` is in the gate deliberately (hyperguild#120). vet and test walk
|
||||
# ./... , which enumerates the packages that EXIST — so a build target
|
||||
# naming a DELETED package is invisible to both. `task build` pointed at
|
||||
# ./cmd/supervisor for months after that directory was removed, and CI
|
||||
# stayed green the whole time because nothing in check ever built.
|
||||
- task: build
|
||||
- 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:
|
||||
cmds:
|
||||
- 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:
|
||||
cmds:
|
||||
- go test -race -count=1 ./...
|
||||
@@ -120,19 +169,6 @@ tasks:
|
||||
- govulncheck ./... || true
|
||||
- cd ingestion && go vet ./...
|
||||
|
||||
supervisor:test:smoke:
|
||||
desc: Smoke test supervisor via MCP (requires start running)
|
||||
cmds:
|
||||
- |
|
||||
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
||||
|
||||
smoke:routing:
|
||||
desc: Boot the routing pod against live LiteLLM + brain and verify _routing logs land
|
||||
cmds:
|
||||
- bash scripts/smoke-routing.sh
|
||||
|
||||
# ── Git / Release ──────────────────────────────────────────────────────────
|
||||
|
||||
tag:
|
||||
|
||||
@@ -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`.
|
||||
+15
-13
@@ -55,18 +55,21 @@ Flags:
|
||||
- `--limit N` — max results (default 5)
|
||||
- `--json` — emit the raw response envelope
|
||||
|
||||
### `hyperguild brain write <type> <slug>`
|
||||
### `hyperguild brain write <wing> <hall> <slug>`
|
||||
|
||||
Reads markdown from stdin, writes a knowledge entry.
|
||||
Reads markdown from stdin, writes it to `brain/wiki/<wing>/<hall>/<slug>.md`
|
||||
with `wing`/`hall`/`created_at` frontmatter injected (there is no legacy
|
||||
`brain/knowledge/` fallback — ADR-0012 / #79, both wing and hall are
|
||||
required).
|
||||
|
||||
```bash
|
||||
$ cat <<EOF | hyperguild brain write knowledge example-lesson
|
||||
$ cat <<EOF | hyperguild brain write homelab facts example-lesson
|
||||
# Example lesson
|
||||
|
||||
## Lesson
|
||||
...
|
||||
EOF
|
||||
knowledge/example-lesson.md
|
||||
wiki/homelab/facts/example-lesson.md
|
||||
```
|
||||
|
||||
### `hyperguild brain pass-rate <skill>`
|
||||
@@ -112,16 +115,15 @@ Flags:
|
||||
- `--out PATH` — output file (default `./.mcp.json`)
|
||||
- `--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.
|
||||
- **client-local** — brain + routing pod. The `routing` entry points at
|
||||
`koala:30310/mcp` (the routing pod, deployed in Plan 6). The
|
||||
`X-Hyperguild-Mode: client-local` header is forward-compat for future
|
||||
modes; the pod treats absent or unknown values as `client-local`.
|
||||
- **sovereign** — brain only, with a `_mode_note` explaining that this
|
||||
mode primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude
|
||||
Code fallback for emergency offline use.
|
||||
- **cloud** — brain + gitea MCP.
|
||||
- **client-local** — brain + gitea MCP. (The former `routing` entry pointing at
|
||||
`koala:30310/mcp` was removed — the routing pod is iceboxed, #75.)
|
||||
- **sovereign** — brain + gitea, with a `_mode_note` explaining that this mode
|
||||
primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude Code fallback
|
||||
for emergency offline use.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -60,13 +60,14 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout,
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return fmt.Errorf("parse flags: %w", err)
|
||||
}
|
||||
if fs.NArg() < 2 {
|
||||
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
|
||||
if fs.NArg() < 3 {
|
||||
return errors.New("wing, hall and slug required (e.g. brain write homelab facts my-slug)")
|
||||
}
|
||||
kind := fs.Arg(0)
|
||||
slug := fs.Arg(1)
|
||||
wing := fs.Arg(0)
|
||||
hall := fs.Arg(1)
|
||||
slug := fs.Arg(2)
|
||||
|
||||
res, err := newBrainClient().Write(ctx, kind, slug, stdin)
|
||||
res, err := newBrainClient().Write(ctx, wing, hall, slug, stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
err := runBrain(
|
||||
context.Background(),
|
||||
[]string{"write", "knowledge", "test-slug"},
|
||||
[]string{"write", "homelab", "facts", "test-slug"},
|
||||
strings.NewReader("# Test\n\nSome body content.\n"),
|
||||
&out, &errBuf,
|
||||
)
|
||||
@@ -111,9 +111,9 @@ func TestRunBrainWrite_Success(t *testing.T) {
|
||||
|
||||
func TestRunBrainWrite_MissingArgs(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
|
||||
err := runBrain(context.Background(), []string{"write", "homelab"}, strings.NewReader("x"), &out, &errBuf)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "type and slug required")
|
||||
assert.Contains(t, err.Error(), "wing, hall and slug required")
|
||||
}
|
||||
|
||||
func TestRunBrainWrite_BackendError(t *testing.T) {
|
||||
@@ -127,7 +127,7 @@ func TestRunBrainWrite_BackendError(t *testing.T) {
|
||||
var out, errBuf bytes.Buffer
|
||||
err := runBrain(
|
||||
context.Background(),
|
||||
[]string{"write", "knowledge", "bad slug"},
|
||||
[]string{"write", "homelab", "facts", "bad slug"},
|
||||
strings.NewReader("body"),
|
||||
&out, &errBuf,
|
||||
)
|
||||
@@ -150,7 +150,7 @@ func TestRunBrainWrite_EmptyStdin(t *testing.T) {
|
||||
t.Setenv("BRAIN_URL", srv.URL)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf)
|
||||
err := runBrain(context.Background(), []string{"write", "homelab", "facts", "empty"}, strings.NewReader(""), &out, &errBuf)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
|
||||
}
|
||||
|
||||
@@ -84,16 +84,17 @@ type WriteResult struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) {
|
||||
func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, content io.Reader) (*WriteResult, error) {
|
||||
body, err := io.ReadAll(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read content: %w", err)
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
}{Type: kind, Slug: slug, Content: string(body)})
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}{Wing: wing, Hall: hall, Filename: filename, Content: string(body)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,23 +65,25 @@ func TestBrainClient_Write_Success(t *testing.T) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var got struct {
|
||||
Type string `json:"type"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.Equal(t, "knowledge", got.Type)
|
||||
assert.Equal(t, "find-h", got.Slug)
|
||||
assert.Equal(t, "homelab", got.Wing)
|
||||
assert.Equal(t, "facts", got.Hall)
|
||||
assert.Equal(t, "find-h", got.Filename)
|
||||
assert.Equal(t, "# body\n", got.Content)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`))
|
||||
_, _ = w.Write([]byte(`{"path":"wiki/homelab/facts/find-h.md"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
|
||||
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
|
||||
res, err := c.Write(context.Background(), "homelab", "facts", "find-h", strings.NewReader("# body\n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "knowledge/find-h.md", res.Path)
|
||||
assert.Equal(t, "wiki/homelab/facts/find-h.md", res.Path)
|
||||
}
|
||||
|
||||
func TestBrainClient_PassRate_Success(t *testing.T) {
|
||||
|
||||
+26
-19
@@ -59,31 +59,40 @@ func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.
|
||||
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 {
|
||||
return map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"brain": map[string]any{
|
||||
"url": brainURL + "/mcp",
|
||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||
},
|
||||
"brain": brainEntry(brainURL),
|
||||
"gitea": giteaEntry(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func modeClientLocal(brainURL string) map[string]any {
|
||||
// The routing pod is iceboxed (#75); the consolidated harness is brain + gitea.
|
||||
return map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
"brain": map[string]any{
|
||||
"url": brainURL + "/mcp",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
"brain": brainEntry(brainURL),
|
||||
"gitea": giteaEntry(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -92,10 +101,8 @@ func modeSovereign(brainURL string) 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).",
|
||||
"mcpServers": map[string]any{
|
||||
"brain": map[string]any{
|
||||
"url": brainURL + "/mcp",
|
||||
"description": "Brain MCP — knowledge query, write, ingestion, session log",
|
||||
},
|
||||
"brain": brainEntry(brainURL),
|
||||
"gitea": giteaEntry(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+11
-17
@@ -35,11 +35,13 @@ func TestRunMode_Cloud_Default(t *testing.T) {
|
||||
servers, ok := got["mcpServers"].(map[string]any)
|
||||
require.True(t, ok, "mcpServers must be a JSON object")
|
||||
assert.Contains(t, servers, "brain")
|
||||
assert.Contains(t, servers, "gitea")
|
||||
assert.NotContains(t, servers, "routing")
|
||||
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()
|
||||
outPath := filepath.Join(dir, ".mcp.json")
|
||||
t.Setenv("BRAIN_URL", "http://koala:30330")
|
||||
@@ -51,17 +53,11 @@ func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
|
||||
got := readJSON(t, outPath)
|
||||
servers := got["mcpServers"].(map[string]any)
|
||||
require.Contains(t, servers, "brain")
|
||||
require.Contains(t, servers, "routing")
|
||||
|
||||
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"])
|
||||
require.Contains(t, servers, "gitea")
|
||||
assert.NotContains(t, servers, "routing", "routing pod iceboxed (#75)")
|
||||
}
|
||||
|
||||
func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
||||
func TestModeGiteaEntryPresentAndWellFormed(t *testing.T) {
|
||||
tmp := t.TempDir() + "/mcp.json"
|
||||
out := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
@@ -73,13 +69,10 @@ func TestModeClientLocalHasRoutingHeader(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(body, &doc))
|
||||
|
||||
servers := doc["mcpServers"].(map[string]any)
|
||||
routing := servers["routing"].(map[string]any)
|
||||
assert.Equal(t, "http://koala:30310/mcp", routing["url"])
|
||||
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"])
|
||||
require.NotContains(t, servers, "routing")
|
||||
gitea, ok := servers["gitea"].(map[string]any)
|
||||
require.True(t, ok, "gitea entry must be present (audit-trail invariant)")
|
||||
assert.Equal(t, "https://git-mcp.d-ma.be/mcp", gitea["url"])
|
||||
}
|
||||
|
||||
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
||||
@@ -94,6 +87,7 @@ func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
|
||||
assert.Contains(t, got, "_mode_note")
|
||||
servers := got["mcpServers"].(map[string]any)
|
||||
assert.Contains(t, servers, "brain")
|
||||
assert.Contains(t, servers, "gitea")
|
||||
assert.NotContains(t, servers, "routing")
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/tier"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# Model selection — first entry per skill is used.
|
||||
# Override per-call by passing model in the MCP tool args.
|
||||
# Model names come from LiteLLM /v1/models (host/name format).
|
||||
|
||||
default_chain:
|
||||
- iguana/qwen3-coder-next
|
||||
|
||||
skills:
|
||||
tdd:
|
||||
chain:
|
||||
- koala/qwen3-coder-30b
|
||||
review:
|
||||
chain:
|
||||
- iguana/devstral
|
||||
debug:
|
||||
chain:
|
||||
- iguana/deepseek-r1-14b
|
||||
spec:
|
||||
chain:
|
||||
- koala/phi4-14b
|
||||
retrospective:
|
||||
chain:
|
||||
- iguana/qwen3-coder-next
|
||||
trainer:
|
||||
chain:
|
||||
- iguana/qwen3-coder-next
|
||||
@@ -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
|
||||
# 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" \
|
||||
-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']]"
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Three needs converge on a single small Go binary:
|
||||
|
||||
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
|
||||
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
|
||||
- [ ] `hyperguild brain write <type> <slug>` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry.
|
||||
- [ ] `hyperguild brain write <wing> <hall> <slug>` reads markdown content from stdin, posts to `/write` with wing/hall/filename, and creates `brain/wiki/<wing>/<hall>/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry. (Updated post-ADR-0012/#79: the legacy `<type> <slug>` shape and the `brain/knowledge/` fallback were removed; wing and hall are now required.)
|
||||
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
|
||||
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
|
||||
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
|
||||
@@ -50,7 +50,7 @@ Three needs converge on a single small Go binary:
|
||||
|
||||
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
|
||||
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
|
||||
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
|
||||
- `brain write <wing> <hall> <slug>` reads stdin, posts `POST /write` with JSON body `{wing, hall, filename, content}`, prints the resulting path on success. (Updated post-ADR-0012/#79: wing and hall are required, replacing the old `<type> <slug>` shape and the `brain/knowledge/` legacy fallback.)
|
||||
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
|
||||
|
||||
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/mathiasbq/supervisor
|
||||
module git.d-ma.be/mathias/hyperguild
|
||||
|
||||
go 1.26.1
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+272
-28
@@ -8,15 +8,21 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
||||
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"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"
|
||||
@@ -26,14 +32,36 @@ import (
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||
"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/watcher"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// claudeSink converts each claudewatcher.Batch into one wiki note under
|
||||
// brain/wiki/claude-sessions/facts/. v1 emits one note per session
|
||||
// keyed by host + session id; classifier-driven hall routing is a
|
||||
// follow-up (hyperguild#27 v2).
|
||||
// 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
|
||||
@@ -61,16 +89,14 @@ func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
slug := "session-" + b.Host + "-" + b.SessionID
|
||||
if _, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Filename: slug,
|
||||
Wing: "claude-sessions",
|
||||
Hall: "facts",
|
||||
Type: "source",
|
||||
Domain: b.ProjectID,
|
||||
Content: sb.String(),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write claude session note: %w", err)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -118,6 +144,83 @@ func envInt(key string, fallback int) int {
|
||||
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.
|
||||
// alertingWarnings names every alert that will not fire under the current
|
||||
// configuration (#107).
|
||||
//
|
||||
// buildNotifier returns a nil interface when BRAIN_NTFY_URL is unset, and every
|
||||
// consumer skips a nil notifier silently — right for an unconfigured homelab,
|
||||
// wrong for an alert whose entire value is that someone finds out. The cost of
|
||||
// the silence is that "configured and working" and "never configured" are
|
||||
// byte-identical in behaviour: no error, no warning, only a message that never
|
||||
// arrives, which nobody notices because nobody is waiting for an alert they
|
||||
// have never seen. Two alerts were dark in production for exactly that reason
|
||||
// (#100), found by firing a real event rather than by reading anything.
|
||||
//
|
||||
// WARN rather than fatal, deliberately. The brain sibling rule
|
||||
// (`empty-credential-must-refuse-to-start-not-switch-modes`) says an empty
|
||||
// credential must be a startup error, never a silent mode switch — but that is
|
||||
// about a control being removed. Here observability is degraded, not a gate
|
||||
// bypassed, and refusing to boot would break the unconfigured-homelab case the
|
||||
// nil notifier exists to support. What the rule does demand, and what this
|
||||
// provides, is that the mode switch stops being silent.
|
||||
//
|
||||
// Pure and env-injected so the message is testable without a running server:
|
||||
// the reason this gap survived is that nothing about it was observable.
|
||||
func alertingWarnings(getenv func(string) string) []string {
|
||||
var out []string
|
||||
if getenv("BRAIN_NTFY_URL") == "" {
|
||||
out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+
|
||||
"brain_edit_root governance rewrites and capture audit degradation will not notify")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -131,6 +234,10 @@ func systemHostname() string {
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
for _, w := range alertingWarnings(os.Getenv) {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
|
||||
port := envOr("INGEST_PORT", "3300")
|
||||
|
||||
@@ -164,6 +271,11 @@ func main() {
|
||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||
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
|
||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||
}
|
||||
@@ -175,6 +287,15 @@ func main() {
|
||||
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
|
||||
// be set together for the path to wire on; otherwise BM25-only.
|
||||
var vectorStore *vectorstore.PGStore
|
||||
@@ -239,6 +360,36 @@ func main() {
|
||||
}
|
||||
|
||||
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 {
|
||||
watcher.Start(ctx, watcher.Config{
|
||||
BrainDir: brainDir,
|
||||
@@ -256,6 +407,19 @@ func main() {
|
||||
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)
|
||||
@@ -267,15 +431,39 @@ func main() {
|
||||
}
|
||||
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,
|
||||
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)
|
||||
}
|
||||
@@ -283,6 +471,29 @@ func main() {
|
||||
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 {
|
||||
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
|
||||
vectorstore.StartSync(ctx, brainDir, vectorStore,
|
||||
@@ -300,17 +511,13 @@ func main() {
|
||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||
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("GET /pass-rate", h.PassRate)
|
||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||
if err != nil {
|
||||
logger.Error("build jwt validator", "err", err)
|
||||
os.Exit(1)
|
||||
if webhookHandler != nil {
|
||||
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||
}
|
||||
if jwtValidator != nil {
|
||||
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
||||
}
|
||||
|
||||
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||
// configured. Static-Bearer-only deployments leave this empty so
|
||||
// clients never see an OAuth challenge.
|
||||
@@ -326,6 +533,43 @@ func main() {
|
||||
|
||||
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
|
||||
// integration UI, which has no static-Bearer field. Setting both
|
||||
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// ingestion/cmd/server/main_test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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)")
|
||||
}
|
||||
|
||||
// --- #107: an unconfigured alert must announce itself ----------------------
|
||||
|
||||
func TestAlertingWarnings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want []string // substrings that must appear across the warnings
|
||||
none bool // true = expect no warnings at all
|
||||
}{
|
||||
{
|
||||
// The failure this exists to prevent: two alerts dark in
|
||||
// production, discovered only by firing a real event and watching
|
||||
// nothing arrive (#100).
|
||||
name: "notifier unconfigured names what will not fire",
|
||||
env: map[string]string{},
|
||||
want: []string{"BRAIN_NTFY_URL", "brain_edit_root", "audit"},
|
||||
},
|
||||
{
|
||||
name: "configured notifier is silent",
|
||||
env: map[string]string{"BRAIN_NTFY_URL": "http://ntfy/brain-alerts"},
|
||||
none: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := alertingWarnings(func(k string) string { return tt.env[k] })
|
||||
if tt.none {
|
||||
assert.Empty(t, got)
|
||||
return
|
||||
}
|
||||
joined := strings.Join(got, " ")
|
||||
for _, w := range tt.want {
|
||||
assert.Contains(t, joined, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+49
-6
@@ -3,29 +3,72 @@ module github.com/mathiasbq/hyperguild/ingestion
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
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 (
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
||||
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/goccy/go-json v0.10.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // 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/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // 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
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.31.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
-5
@@ -1,12 +1,47 @@
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
||||
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.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.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/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/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -15,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/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/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/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
@@ -27,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/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
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.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/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.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.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.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/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/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/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/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/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 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
// Aliased: this package's own frontmatter type (below) would collide
|
||||
// with the imported package's name otherwise.
|
||||
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Fence detection itself is frontmatter.Split (hyperguild#138) — this
|
||||
// package's own contribution is purely the ordered, line-preserving fmLine
|
||||
// shape below, which a shared parser has no business owning since it's
|
||||
// specific to how brain_update does a surgical, byte-round-trip edit.
|
||||
func parseFrontmatter(src string) (frontmatter, string) {
|
||||
var fm frontmatter
|
||||
blockBytes, restBytes, ok := sharedfm.Split([]byte(src))
|
||||
if !ok {
|
||||
return fm, src
|
||||
}
|
||||
// sharedfm.Split's block/rest boundary sits one byte earlier than this
|
||||
// package's always has: block keeps the "\n" immediately before the
|
||||
// closing fence, and rest keeps the "\n" immediately after it (both by
|
||||
// design, for Fix's own reconstruction). This package's block/body
|
||||
// never included either — TrimSuffix/TrimPrefix restore that exact
|
||||
// boundary. Split guarantees ok=true only when "\n---\n" matched, so
|
||||
// both trims are always exactly one byte, never ambiguous.
|
||||
block := strings.TrimSuffix(string(blockBytes), "\n")
|
||||
body := strings.TrimPrefix(string(restBytes), "\n")
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
// An indented line is a continuation (a YAML block-list item, a
|
||||
// wrapped value) never a top-level key, regardless of whether it
|
||||
// happens to contain a colon — " - 'Chapter 3: The Reckoning'" is
|
||||
// not a key named "- 'Chapter 3" (hyperguild#128). Checked before
|
||||
// Cut so a colon inside one never reaches the key/value path.
|
||||
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
||||
fm.lines = append(fm.lines, fmLine{raw: line})
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
switch {
|
||||
case l.key == "":
|
||||
b.WriteString(l.raw)
|
||||
case l.value == "":
|
||||
// A key with nothing after the colon (the YAML block-list
|
||||
// shape: "aliases:" followed by indented "- item" lines on
|
||||
// their own raw lines) must stay bare — "key: " with a
|
||||
// trailing space is a different byte sequence than the
|
||||
// original, and this type's whole purpose (see the package
|
||||
// doc) is a byte-preserving round-trip.
|
||||
b.WriteString(l.key)
|
||||
b.WriteByte(':')
|
||||
default:
|
||||
b.WriteString(l.key)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(l.value)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("---\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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())
|
||||
}
|
||||
|
||||
func TestParseFrontmatterIndentedListItemWithColonIsNotMisparsedAsKey(t *testing.T) {
|
||||
// hyperguild#128: an indented YAML block-list item that happens to
|
||||
// contain a colon (e.g. a title with a subtitle) is a continuation
|
||||
// line, never a top-level key — strings.Cut doesn't know that just
|
||||
// because TrimSpace(key) happens to be non-empty. Misparsing it as a
|
||||
// key/value pair and re-rendering strips its indentation, de-indenting
|
||||
// it out of the list it belongs to. Same bug class already found and
|
||||
// fixed in the sibling internal/wiki package's parseFrontmatterBlock
|
||||
// (hyperguild#126 review round 3) — this package was the original the
|
||||
// reimplementation was modeled on and had the gap too.
|
||||
src := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\nbody\n"
|
||||
fm, _ := parseFrontmatter(src)
|
||||
fm.set("updated_at", "2026-09-23T00:00:00Z")
|
||||
|
||||
out := fm.render()
|
||||
assert.Contains(t, out, " - 'Chapter 3: The Reckoning'",
|
||||
"the indented list item must survive verbatim, not get re-emitted as a de-indented fake key")
|
||||
assert.NotContains(t, out, "\n- 'Chapter 3",
|
||||
"must not be split into a fake key/value pair at column 0 (as opposed to surviving indented, asserted above)")
|
||||
}
|
||||
|
||||
func TestFrontmatterRenderEmptyValueKeyStaysBareNoTrailingSpace(t *testing.T) {
|
||||
// Companion to the indented-list-item fix: "aliases:" (a key with
|
||||
// nothing after the colon, the YAML block-list header) must round-trip
|
||||
// bare, not as "aliases: " with a trailing space — a different byte
|
||||
// sequence than the original, breaking this type's whole reason for
|
||||
// being a line-preserving view rather than a YAML re-marshal.
|
||||
// render() returns only the frontmatter block (see
|
||||
// TestFrontmatterRenderRoundTrips), no trailing body.
|
||||
src := "---\naliases:\n - one\n - two\n---\n"
|
||||
fm, _ := parseFrontmatter(src)
|
||||
assert.Equal(t, src, fm.render())
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
@@ -51,12 +52,11 @@ type queryRequest struct {
|
||||
}
|
||||
|
||||
type writeRequest struct {
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Wing string `json:"wing,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 {
|
||||
@@ -108,19 +108,21 @@ func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// WriteNoteOptions configures how a brain note is written.
|
||||
//
|
||||
// When both Wing and Hall are non-empty, the note routes into the
|
||||
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
|
||||
// wing/hall/created_at injected into its YAML frontmatter.
|
||||
//
|
||||
// When either is empty, the note falls back to brain/knowledge/<filename>
|
||||
// with optional type/domain frontmatter (legacy behaviour).
|
||||
// Wing and Hall are both required: the note routes into the structured
|
||||
// wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets wing/hall/created_at
|
||||
// injected into its YAML frontmatter. There is no other valid destination
|
||||
// (the legacy brain/knowledge/ fallback was removed — ADR-0012 / #79).
|
||||
type WriteNoteOptions struct {
|
||||
Content string
|
||||
Filename string
|
||||
Type string
|
||||
Domain string
|
||||
Wing string
|
||||
Hall string
|
||||
Content string
|
||||
Filename string
|
||||
Wing string
|
||||
Hall string
|
||||
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
|
||||
// Origin/WrittenBy are server-owned provenance (hyperguild#145): any
|
||||
// origin/written_by in Content's own frontmatter is discarded, so a
|
||||
// caller cannot claim a more trusted origin than it authenticated as.
|
||||
Origin string
|
||||
WrittenBy string
|
||||
}
|
||||
|
||||
// WriteNote writes a markdown note into the brain. Returns the path
|
||||
@@ -129,14 +131,21 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
if opts.Content == "" {
|
||||
return "", fmt.Errorf("content is required")
|
||||
}
|
||||
if opts.Wing == "" || opts.Hall == "" {
|
||||
return "", fmt.Errorf("wing and hall are required")
|
||||
}
|
||||
|
||||
if opts.Wing != "" && opts.Hall != "" {
|
||||
return writeHallNote(brainDir, opts)
|
||||
// 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
|
||||
}
|
||||
if opts.Wing != "" || opts.Hall != "" {
|
||||
return "", fmt.Errorf("wing and hall must be set together")
|
||||
}
|
||||
return writeLegacyNote(brainDir, opts)
|
||||
opts.Content = clean
|
||||
|
||||
return writeHallNote(brainDir, opts)
|
||||
}
|
||||
|
||||
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
|
||||
@@ -150,93 +159,173 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create hall dir: %w", 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)
|
||||
}
|
||||
|
||||
emitted := make(map[string]bool, 6)
|
||||
|
||||
var fm strings.Builder
|
||||
fm.WriteString("---\n")
|
||||
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
||||
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||
if opts.Type != "" {
|
||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
||||
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
if opts.Domain != "" {
|
||||
fmt.Fprintf(&fm, "domain: %s\n", 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")
|
||||
}
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
|
||||
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
||||
final, err := StampProvenance(fm.String()+body, opts.Origin, opts.WrittenBy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The directory is created only once the note is known to be writable:
|
||||
// a refused write leaves nothing behind, not even an empty hall dir
|
||||
// (brain#21; provenance refusal joins the envelope refusal here).
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create hall dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(final), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
||||
// callers that have not adopted the wing/hall taxonomy.
|
||||
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
filename := opts.Filename
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
|
||||
}
|
||||
|
||||
rawDir := filepath.Join(brainDir, "knowledge")
|
||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create raw dir: %w", err)
|
||||
}
|
||||
|
||||
finalContent := opts.Content
|
||||
if opts.Type != "" || opts.Domain != "" {
|
||||
var fm strings.Builder
|
||||
fm.WriteString("---\n")
|
||||
if opts.Type != "" {
|
||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
||||
// 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
|
||||
}
|
||||
if opts.Domain != "" {
|
||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||
_, value, found := strings.Cut(f.lines[0], ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
finalContent = fm.String() + opts.Content
|
||||
out[f.key] = strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
if strings.ContainsAny(filename, `/\`) {
|
||||
return "", fmt.Errorf("invalid filename")
|
||||
}
|
||||
base := filepath.Base(filename)
|
||||
if base == "." || base == ".." || base == "" {
|
||||
return "", fmt.Errorf("invalid filename")
|
||||
}
|
||||
if !strings.HasSuffix(base, ".md") {
|
||||
base += ".md"
|
||||
}
|
||||
dest := filepath.Join(rawDir, base)
|
||||
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return filepath.ToSlash(rel), nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Write handles POST /write — write raw content to brain/knowledge/.
|
||||
// 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
|
||||
}
|
||||
|
||||
// Write handles POST /write — write a note into brain/wiki/<wing>/<hall>/.
|
||||
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
|
||||
var req writeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
|
||||
// Field by field, not a struct conversion: provenance is server-owned and
|
||||
// this door carries no principal, so it stamps origin: unknown
|
||||
// (hyperguild#145). A JSON body must never be able to set it.
|
||||
relPath, err := WriteNote(h.brainDir, WriteNoteOptions{
|
||||
Content: req.Content,
|
||||
Filename: req.Filename,
|
||||
Wing: req.Wing,
|
||||
Hall: req.Hall,
|
||||
SourceType: req.SourceType,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("write failed", "err", err)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Wing != "" && req.Hall != "" {
|
||||
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
||||
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
||||
}
|
||||
// WriteNote errors out above unless both are set, so this always runs —
|
||||
// no legacy fallback path left to guard against.
|
||||
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
|
||||
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
|
||||
}
|
||||
writeJSON(w, map[string]string{"path": relPath})
|
||||
}
|
||||
@@ -332,11 +421,19 @@ func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
|
||||
}
|
||||
|
||||
// supportedExtensions lists file extensions that IngestPath will process.
|
||||
var supportedExtensions = map[string]bool{
|
||||
".md": true,
|
||||
".txt": true,
|
||||
".pdf": true,
|
||||
// isSupportedExtension reports whether IngestPath will process ext.
|
||||
// .docx/.xlsx/.pptx/.png/.jpg/.jpeg require docmark (ADR-0013) and are only
|
||||
// supported when DOCMARK_URL is configured — checked per-call (not cached at
|
||||
// package init) so it reflects the environment at request time.
|
||||
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.
|
||||
@@ -369,7 +466,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !supportedExtensions[ext] {
|
||||
if !isSupportedExtension(ext) {
|
||||
return nil
|
||||
}
|
||||
content, readErr := extract.Text(path)
|
||||
@@ -397,7 +494,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
ext := strings.ToLower(filepath.Ext(req.Path))
|
||||
if !supportedExtensions[ext] {
|
||||
if !isSupportedExtension(ext) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||
return
|
||||
}
|
||||
@@ -453,7 +550,8 @@ func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||
// No principal on this door: pages are stamped origin: unknown (#145).
|
||||
result, err := pipeline.RunRaw(r.Context(), h.brainDir, req.Source, req.Pages, req.DryRun)
|
||||
if err != nil {
|
||||
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "ingest error")
|
||||
@@ -483,6 +581,44 @@ func (h *Handler) BackfillRefs(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
// Field by field: this door carries no principal, so the promoted note is
|
||||
// stamped origin: unknown and the body cannot set provenance (#145).
|
||||
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
||||
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"path": rel})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
|
||||
@@ -65,11 +65,13 @@ func TestQuery_ReturnsResults(t *testing.T) {
|
||||
assert.NotEmpty(t, results)
|
||||
}
|
||||
|
||||
func TestWrite_CreatesKnowledgeFile(t *testing.T) {
|
||||
func TestWrite_CreatesWikiFile(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"content": "# Test note\n\nSome content.",
|
||||
"filename": "test-note.md",
|
||||
"filename": "test-note",
|
||||
"wing": "homelab",
|
||||
"hall": "facts",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -81,11 +83,25 @@ func TestWrite_CreatesKnowledgeFile(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.NotEmpty(t, resp["path"])
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "test-note.md"))
|
||||
content, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "test-note.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "Some content.")
|
||||
}
|
||||
|
||||
// TestWrite_RequiresWingAndHall is the HTTP-contract counterpart of
|
||||
// TestWriteNote_RequiresWingAndHall: a caller (e.g. the MCP brain_write
|
||||
// handler) that omits wing/hall gets a 400, not a silent legacy write.
|
||||
func TestWrite_RequiresWingAndHall(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{"content": "no destination"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.Write(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestQuery_RequiresQuery(t *testing.T) {
|
||||
_, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{"limit": 5})
|
||||
@@ -97,13 +113,131 @@ func TestQuery_RequiresQuery(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
||||
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",
|
||||
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")
|
||||
}
|
||||
|
||||
// TestWriteNote_RequiresWingAndHall pins the post-legacy-removal contract
|
||||
// (hyperguild#79 / ADR-0012): with the knowledge/ fallback gone, wing+hall
|
||||
// is the only valid destination, so omitting either is a caller error, not
|
||||
// a silent fallback.
|
||||
func TestWriteNote_RequiresWingAndHall(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
wing string
|
||||
hall string
|
||||
}{
|
||||
{"neither set", "", ""},
|
||||
{"wing only", "homelab", ""},
|
||||
{"hall only", "", "facts"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "body",
|
||||
Wing: tc.wing,
|
||||
Hall: tc.hall,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "wing and hall are required")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"content": "Some learning.",
|
||||
"filename": "typed-note.md",
|
||||
"type": "concept",
|
||||
"domain": "software",
|
||||
"content": "auto name",
|
||||
"wing": "homelab",
|
||||
"hall": "facts",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -111,26 +245,54 @@ func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
|
||||
h.Write(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "typed-note.md"))
|
||||
entries, err := os.ReadDir(filepath.Join(dir, "wiki", "homelab", "facts"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "type: concept")
|
||||
assert.Contains(t, string(content), "domain: software")
|
||||
assert.Contains(t, string(content), "Some learning.")
|
||||
require.Len(t, entries, 1)
|
||||
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
|
||||
}
|
||||
|
||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||
// TestWrite_CLIPayloadRoundTrips exercises the real Handler.Write (not a
|
||||
// hand-rolled mock) over an actual HTTP round trip, POSTing the exact JSON
|
||||
// envelope cmd/hyperguild's brainClient.Write sends: {wing, hall, filename,
|
||||
// content}. cmd/hyperguild lives in a separate Go module from ingestion
|
||||
// (no shared go.work), so a Go-level test can't import brainClient
|
||||
// directly — this test instead pins the wire contract from the server
|
||||
// side and proves a real `hyperguild brain write <wing> <hall> <slug>`
|
||||
// invocation writes a real file with real frontmatter under wiki/<wing>/<hall>/.
|
||||
// A prior version of this contract test on the client side (in
|
||||
// cmd/hyperguild/http_test.go) used only an httptest.NewServer with a
|
||||
// hand-rolled mock body, so it stayed green through a regression where the
|
||||
// server required wing/hall but the client never sent them (hyperguild#79
|
||||
// PR review).
|
||||
func TestWrite_CLIPayloadRoundTrips(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
srv := httptest.NewServer(http.HandlerFunc(h.Write))
|
||||
defer srv.Close()
|
||||
|
||||
h.Write(rec, req)
|
||||
payload, err := json.Marshal(struct {
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}{Wing: "homelab", Hall: "facts", Filename: "cli-roundtrip", Content: "# CLI roundtrip\n\nReal write.\n"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
|
||||
// +1 because setup already wrote tdd.md
|
||||
assert.Len(t, entries, 2)
|
||||
assert.True(t, strings.HasSuffix(entries[1].Name(), ".md"))
|
||||
resp, err := http.Post(srv.URL+"/write", "application/json", bytes.NewReader(payload)) //nolint:noctx
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var out struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||
assert.Equal(t, "wiki/homelab/facts/cli-roundtrip.md", out.Path)
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "cli-roundtrip.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(written), "wing: homelab")
|
||||
assert.Contains(t, string(written), "hall: facts")
|
||||
assert.Contains(t, string(written), "Real write.")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -330,3 +492,102 @@ func TestIngestPath_Directory(t *testing.T) {
|
||||
require.True(t, ok, "pages must be an array")
|
||||
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,226 @@
|
||||
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
|
||||
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
||||
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||
Origin string
|
||||
WrittenBy string
|
||||
}
|
||||
|
||||
// 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.
|
||||
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(promoted), 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
|
||||
}
|
||||
|
||||
// RejectOptions identifies a raw note to reject and why.
|
||||
type RejectOptions struct {
|
||||
Filename string // basename in brain/raw/
|
||||
Reason string // optional
|
||||
}
|
||||
|
||||
// RejectNote records a human decision not to promote a brain/raw/ note,
|
||||
// without silently losing it: it stamps rejected_at (+ reject_reason when
|
||||
// given) into frontmatter and moves the file to brain/raw/rejected/, using
|
||||
// the same write-then-delete safety PromoteNote uses — the source survives
|
||||
// any write failure — and the same basename-only filename validation.
|
||||
//
|
||||
// A rejected note never entered the wiki, so no index rebuild or
|
||||
// auto-tunnel run: unlike PromoteNote, this is the end of the note's
|
||||
// journey, not the start of a new one. It stays out of ListPending's view
|
||||
// (raw/rejected/ is a subdirectory, and ListPending's os.ReadDir on raw/
|
||||
// does not recurse), so a rejected note is never re-surfaced as pending —
|
||||
// the human decision, once made, stays made, the same principle behind
|
||||
// this estate's "issue status is surfaced, not auto-closed, but once a
|
||||
// human decides, the decision is recorded" rule applied one level earlier.
|
||||
func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
||||
base := filepath.Base(opts.Filename)
|
||||
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
||||
if _, statErr := os.Stat(dest); statErr == nil {
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
||||
}
|
||||
|
||||
fm, body := parseFrontmatter(string(raw))
|
||||
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
||||
if opts.Reason != "" {
|
||||
fm.set("reject_reason", opts.Reason)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create rejected 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 rejected note: %w", err)
|
||||
}
|
||||
if err := os.Remove(src); err != nil {
|
||||
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return filepath.ToSlash(rel), 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,203 @@
|
||||
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")
|
||||
}
|
||||
|
||||
// --- RejectNote (hyperguild#81) ---------------------------------------------
|
||||
|
||||
func TestRejectHappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-04-23-fed-sentiment-scrape.md",
|
||||
"---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Sentiment scrape\n\nbody.\n")
|
||||
|
||||
rel, err := RejectNote(dir, RejectOptions{
|
||||
Filename: "2026-04-23-fed-sentiment-scrape.md", Reason: "generic scrape, not homelab-relevant",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "raw/rejected/2026-04-23-fed-sentiment-scrape.md", rel)
|
||||
|
||||
// Source removed from the pending queue.
|
||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "2026-04-23-fed-sentiment-scrape.md"))
|
||||
assert.True(t, os.IsNotExist(statErr), "source removed from raw/ after reject")
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
s := string(got)
|
||||
assert.Contains(t, s, "created_at: 2026-04-23T13:20:07Z", "original created_at preserved")
|
||||
assert.Contains(t, s, "rejected_at:")
|
||||
assert.Contains(t, s, "reject_reason: generic scrape, not homelab-relevant")
|
||||
assert.Contains(t, s, "# Sentiment scrape", "body preserved for later reference")
|
||||
|
||||
// Rejected notes never entered the wiki, and are no longer pending.
|
||||
pending, err := ListPending(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending)
|
||||
}
|
||||
|
||||
func TestRejectWithoutReason(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||
rel, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
|
||||
require.NoError(t, err)
|
||||
got, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
assert.NotContains(t, string(got), "reject_reason:", "no reason given, none stamped")
|
||||
}
|
||||
|
||||
func TestRejectMissingSourceErrors(t *testing.T) {
|
||||
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "ghost.md"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRejectRejectsTraversalFilename(t *testing.T) {
|
||||
_, err := RejectNote(t.TempDir(), RejectOptions{Filename: "../escape.md"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRejectCollisionNoOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "raw", "rejected", "x.md")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||
require.NoError(t, os.WriteFile(dest, []byte("EXISTING\n"), 0o644))
|
||||
writeRaw(t, dir, "x.md", "NEW\n")
|
||||
|
||||
_, err := RejectNote(dir, RejectOptions{Filename: "x.md"})
|
||||
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", "x.md"))
|
||||
assert.NoError(t, statErr, "source preserved on collision (atomic: no delete without write)")
|
||||
}
|
||||
|
||||
func TestRejectedNotesAreNotListedAsPendingAgain(t *testing.T) {
|
||||
// ListPending already skips tunnel-candidates-*.md; confirm raw/rejected/
|
||||
// (a subdirectory) doesn't leak into the top-level pending listing either
|
||||
// — os.ReadDir on raw/ doesn't recurse, but pin it so a future change to
|
||||
// ListPending can't silently start surfacing rejected notes as pending
|
||||
// again.
|
||||
dir := t.TempDir()
|
||||
writeRaw(t, dir, "2026-06-01-x.md", "body\n")
|
||||
_, err := RejectNote(dir, RejectOptions{Filename: "2026-06-01-x.md"})
|
||||
require.NoError(t, err)
|
||||
|
||||
pending, err := ListPending(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending, "a rejected note must not still appear as pending")
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
||||
)
|
||||
|
||||
// StampProvenance applies server-owned provenance (hyperguild#145) to note
|
||||
// content. See package provenance for the rule; an error means the stamped
|
||||
// note would not read back as stamped, and the write must be refused.
|
||||
func StampProvenance(content, origin, writtenBy string) (string, error) {
|
||||
return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||
}
|
||||
|
||||
// StampProvenanceFile applies StampProvenance to a brain-relative note path.
|
||||
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
|
||||
_, abs, err := resolveWithin(brainDir, relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
)
|
||||
|
||||
// REST POST /write carries no principal: it stamps origin: unknown, and no
|
||||
// spelling of provenance in its body reaches the note (hyperguild#145).
|
||||
func TestRESTWriteStampsUnknownAndIgnoresBodyProvenance(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"content": "---\norigin: static\n---\n# R\n\nbody", "filename": "rest-prov", "wing": "homelab", "hall": "facts",
|
||||
"origin": "static", "written_by": "local-cli", "Origin": "static", "WrittenBy": "local-cli",
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Write(rec, httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)))
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
b, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "rest-prov.md"))
|
||||
require.NoError(t, err)
|
||||
var m map[string]any
|
||||
require.NoError(t, yaml.Unmarshal([]byte(strings.SplitN(string(b), "---\n", 3)[1]), &m))
|
||||
assert.Equal(t, "unknown", m["origin"])
|
||||
assert.NotContains(t, m, "written_by")
|
||||
}
|
||||
|
||||
// A principal name that is not a bare word is quoted, so it cannot start a
|
||||
// new frontmatter line (e.g. a JWT sub containing a newline).
|
||||
func TestStampProvenanceQuotesAPrincipalThatCouldBreakTheLine(t *testing.T) {
|
||||
out, err := api.StampProvenance("---\ntitle: t\n---\nbody\n", "jwt", "evil\norigin: static")
|
||||
require.NoError(t, err)
|
||||
fm := strings.SplitN(out, "---\n", 3)[1]
|
||||
var m map[string]any
|
||||
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
|
||||
assert.Equal(t, "jwt", m["origin"])
|
||||
assert.Equal(t, "evil\norigin: static", m["written_by"])
|
||||
assert.Equal(t, 1, strings.Count(fm, "\norigin:")+boolToInt(strings.HasPrefix(fm, "origin:")))
|
||||
}
|
||||
|
||||
func TestStampProvenanceAddsFrontmatterWhenAbsentAndKeepsBody(t *testing.T) {
|
||||
out, err := api.StampProvenance("# no fm\n\norigin: static in the body is prose\n", "static", "local-cli")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(out, "---\norigin: static\nwritten_by: local-cli\n---\n# no fm"))
|
||||
assert.Contains(t, out, "origin: static in the body is prose", "body lines are never touched")
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Round 2 (R3/R4): the REST ingest doors write through the pipeline, which
|
||||
// now stamps every page; a title newline cannot forge provenance for the
|
||||
// brain's line-oriented reader.
|
||||
func TestRESTIngestRawStampsUnknownAndTitleNewlineCannotForge(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"source": "zqrest", "pages": []map[string]any{
|
||||
{"title": "Zqrestinj\norigin: static\nwritten_by: local-cli\nx: 'y", "type": "concept", "content": "zqrestinj body"},
|
||||
},
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h.IngestRaw(rec, httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body)))
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var resp struct{ Pages []string }
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.NotEmpty(t, resp.Pages)
|
||||
for _, p := range resp.Pages {
|
||||
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
|
||||
require.NoError(t, err)
|
||||
fm := strings.SplitN(string(b), "---\n", 3)[1]
|
||||
var m map[string]any
|
||||
require.NoError(t, yaml.Unmarshal([]byte(fm), &m))
|
||||
assert.Equal(t, "unknown", m["origin"])
|
||||
assert.NotContains(t, m, "written_by")
|
||||
for _, line := range strings.Split(fm, "\n") {
|
||||
assert.False(t, strings.HasPrefix(line, "origin: static"), "line reader sees forged origin in %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTIngestStampsUnknown(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{"content": "Some article text about shaping work.", "source": "zqllm"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Ingest(rec, httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body)))
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var resp struct{ Pages []string }
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.NotEmpty(t, resp.Pages, "stub pipeline must write at least one page")
|
||||
for _, p := range resp.Pages {
|
||||
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, strings.SplitN(string(b), "---\n", 3)[1], "origin: unknown\n", p)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused write leaves nothing behind, not even an empty hall directory
|
||||
// (brain#21's invariant, round 3 non-blocking finding).
|
||||
func TestWriteNoteRefusedForProvenanceLeavesNoDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "# x\n\nbody", Wing: "freshwing", Hall: "facts", Filename: "x", Origin: "jwt", WrittenBy: "bad\xff",
|
||||
})
|
||||
require.Error(t, err)
|
||||
_, statErr := os.Stat(filepath.Join(dir, "wiki", "freshwing"))
|
||||
assert.True(t, os.IsNotExist(statErr), "no wing directory may be created by a refused write")
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
)
|
||||
|
||||
// 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
|
||||
// Origin/WrittenBy re-stamp provenance to the updating principal: the
|
||||
// body is replaced wholesale, so the last writer is the author of what
|
||||
// a reader sees (hyperguild#145). Never taken from Content's fragment.
|
||||
Origin string
|
||||
WrittenBy string
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// A caller may lead opts.Content with its own frontmatter fence to set or
|
||||
// change fields on supersede (e.g. the claim envelope). Split it off
|
||||
// before it reaches the body — left un-split, it would render as a
|
||||
// second, inert `---`-fenced block of literal text rather than taking
|
||||
// effect, and any claim-envelope fields inside it would reach disk
|
||||
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
||||
// writeHallNote must guard a change to the same fields).
|
||||
contentFM, contentBody := parseFrontmatter(opts.Content)
|
||||
|
||||
fm, _ := parseFrontmatter(string(prior))
|
||||
for _, l := range contentFM.lines {
|
||||
// updated_at/supersedes/supersede_reason are this function's own
|
||||
// output, not something a caller's fragment sets — updated_at and
|
||||
// supersedes are already unconditionally re-set below regardless of
|
||||
// what the loop does with them, but supersede_reason is only
|
||||
// re-set when opts.Reason is non-empty, so without this skip a
|
||||
// caller could set it via its own fragment and have it survive
|
||||
// even when Reason is empty, bypassing the one intended entry
|
||||
// point for that field (hyperguild#125, found in #111's review).
|
||||
// Skipping all three here rather than relying on the unconditional
|
||||
// overwrites below to save the other two keeps the invariant
|
||||
// explicit at the one place it can be silently broken by a future
|
||||
// reordering, not implicit in call order.
|
||||
if l.key == "" || l.key == "updated_at" || l.key == "supersedes" || l.key == "supersede_reason" ||
|
||||
l.key == "origin" || l.key == "written_by" {
|
||||
continue
|
||||
}
|
||||
fm.set(l.key, l.value)
|
||||
}
|
||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||
fm.set("supersedes", priorHash)
|
||||
if opts.Reason != "" {
|
||||
fm.set("supersede_reason", opts.Reason)
|
||||
}
|
||||
|
||||
// Validate the envelope AFTER the merge, against the full field set that
|
||||
// is about to reach disk — not just the caller's fragment. Validating the
|
||||
// fragment alone would miss a caller that supersedes claim_provenance to
|
||||
// empty while half_life/applies survive untouched from the prior note:
|
||||
// each side looks individually fine, but the merged result is an
|
||||
// unprovenanced envelope, which is the exact laundering this check
|
||||
// exists to catch. (Caught in review before merge, not filed after —
|
||||
// worth being honest about in the history: this second pass was the
|
||||
// actual fix; the first one validated the wrong document.)
|
||||
envelope := make(map[string]string, len(fm.lines))
|
||||
for _, l := range fm.lines {
|
||||
if l.key != "" {
|
||||
envelope[l.key] = l.value
|
||||
}
|
||||
}
|
||||
if err := claim.ValidateEnvelope(envelope); err != nil {
|
||||
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
||||
}
|
||||
|
||||
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
out := []byte(stamped)
|
||||
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,277 @@
|
||||
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 TestUpdateNoteRejectsInvalidEnvelopeAndWritesNothing(t *testing.T) {
|
||||
// hyperguild#111: WriteNote validates the claim envelope before create;
|
||||
// UpdateNote didn't before supersede, so the same rule must hold on
|
||||
// change as on birth, fail-closed, nothing written on rejection.
|
||||
brainDir := t.TempDir()
|
||||
original := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Old\n\nold body\n"
|
||||
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel,
|
||||
Content: "---\nhalf_life: forever\n---\n# New\n\nnew body\n",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "claim envelope")
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||
}
|
||||
|
||||
func TestUpdateNoteRejectsAppliesWithoutProvenance(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
original := "---\nwing: jepa-fx\nhall: facts\n---\nold\n"
|
||||
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel,
|
||||
Content: "---\napplies: universal\n---\nnew\n",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "claim envelope")
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, string(got))
|
||||
}
|
||||
|
||||
func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(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\n---\nold\n")
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel,
|
||||
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nnew body\n",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
s := string(got)
|
||||
assert.Contains(t, s, "half_life: durable")
|
||||
assert.Contains(t, s, "applies: universal")
|
||||
assert.Contains(t, s, "claim_provenance: asserted")
|
||||
assert.Contains(t, s, "wing: jepa-fx", "prior fields survive")
|
||||
assert.Contains(t, s, "new body")
|
||||
assert.Equal(t, 2, strings.Count(s, "---\n"),
|
||||
"exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block")
|
||||
}
|
||||
|
||||
func TestUpdateNoteRejectsClearingProvenanceWhileEnvelopeSurvives(t *testing.T) {
|
||||
// Found in review, not written speculatively: validating only the
|
||||
// caller's fragment (rather than the merged result) let a caller blank
|
||||
// out claim_provenance alone while half_life/applies survived from the
|
||||
// prior note untouched — each side looks fine on its own, the merged
|
||||
// envelope on disk is the unprovenanced one the whole check exists to
|
||||
// catch.
|
||||
brainDir := t.TempDir()
|
||||
original := "---\nwing: jepa-fx\nhall: facts\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nold\n"
|
||||
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel,
|
||||
Content: "---\nclaim_provenance: \n---\nnew body\n",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "claim envelope")
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||
}
|
||||
|
||||
func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) {
|
||||
// Regression pin: plain fence-less content (the common case) must behave
|
||||
// exactly as it did before the envelope-merge fix.
|
||||
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.NotEqual(t, hash, priorHash)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
s := string(got)
|
||||
assert.Contains(t, s, "# New")
|
||||
assert.Contains(t, s, "custom: keep-me")
|
||||
assert.Contains(t, s, "supersedes: "+priorHash)
|
||||
}
|
||||
|
||||
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 TestUpdateNoteSupersedeReasonNotSettableViaCallerFragmentWhenReasonOptionEmpty(t *testing.T) {
|
||||
// hyperguild#125, found in #111's review: the merge loop applied every
|
||||
// field from the caller's leading frontmatter fragment, including
|
||||
// supersede_reason -- which is only re-set unconditionally when
|
||||
// opts.Reason is non-empty, so a caller could set it via its own
|
||||
// fragment and have it survive even with Reason left empty, bypassing
|
||||
// the one intended entry point for that field.
|
||||
brainDir := t.TempDir()
|
||||
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel,
|
||||
Content: "---\nsupersede_reason: snuck in via the fragment\n---\nnew\n",
|
||||
// Reason deliberately left empty.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(got), "supersede_reason",
|
||||
"supersede_reason must not appear at all when opts.Reason is empty, regardless of what the fragment tried to set")
|
||||
}
|
||||
|
||||
func TestUpdateNoteSupersedeReasonFromOptionStillWorks(t *testing.T) {
|
||||
// Regression pin: the legitimate entry point (opts.Reason) must be
|
||||
// completely unaffected by #125's fix.
|
||||
brainDir := t.TempDir()
|
||||
rel := seedNote(t, brainDir, "wiki/a/facts/n.md", "---\nwing: a\nhall: facts\n---\nold\n")
|
||||
|
||||
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||
Path: rel, Content: "new\n", Reason: "facts changed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "supersede_reason: facts changed")
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
||||
needle := strings.ToLower(strings.TrimSpace(title))
|
||||
if needle == "" {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -69,7 +82,7 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
||||
out = append(out, TunnelCandidate{
|
||||
TargetPath: filepath.ToSlash(rel),
|
||||
MatchedTerm: title,
|
||||
Exact: isWholeWord(lowerContent, idx, len(needle)),
|
||||
Exact: isWholeWord(haystack, idx, len(needle)),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -79,6 +92,11 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
||||
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
|
||||
// bounded by non-alphanumeric characters (or string edges).
|
||||
func isWholeWord(s string, idx, n int) bool {
|
||||
|
||||
@@ -102,16 +102,29 @@ func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
|
||||
|
||||
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||
"---\ntitle: Routing\n---\nbody.\n")
|
||||
seedNote(t, dir, "wiki/x/facts/routing-table.md",
|
||||
"---\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||
|
||||
// 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.Len(t, candidates, 1)
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
// Existing note in a different wing whose title is "Routing".
|
||||
seedNote(t, dir, "wiki/other/facts/routing.md",
|
||||
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
|
||||
// Source note in another wing whose body mentions "rerouting" (substring match only).
|
||||
// Existing note in a different wing whose title is "Routing Table".
|
||||
seedNote(t, dir, "wiki/other/facts/routing-table.md",
|
||||
"---\nwing: other\nhall: facts\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||
// 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",
|
||||
"---\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,
|
||||
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
|
||||
"wiki/jepa-fx/facts/new.md", "routing tables rebuilt"))
|
||||
|
||||
// Source must not get auto-linked (fuzzy).
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
||||
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.
|
||||
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])
|
||||
require.NoError(t, err)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -175,3 +189,35 @@ func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) {
|
||||
assert.Contains(t, s, "[[a/facts/old]]")
|
||||
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,
|
||||
Wing: n.Wing,
|
||||
Hall: n.Hall,
|
||||
Origin: n.Origin,
|
||||
WrittenBy: n.WrittenBy,
|
||||
})
|
||||
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, Origin: n.Origin, WrittenBy: n.WrittenBy}
|
||||
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,133 @@
|
||||
// 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
|
||||
AuthPath string // server-derived: "static" or "jwt"; stamped as note provenance (#145)
|
||||
}
|
||||
|
||||
// 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,127 @@
|
||||
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: wing+hall are the only routing fields
|
||||
// (the legacy type/domain params were removed — ADR-0012 / #79). Reason is
|
||||
// the supersede rationale, used only by Update.
|
||||
type Note struct {
|
||||
Content string
|
||||
Filename string
|
||||
Wing string
|
||||
Hall string
|
||||
Reason string
|
||||
// Provenance (hyperguild#145) is stamped by the server from the
|
||||
// authenticated principal, never taken from Content's frontmatter.
|
||||
// Origin is "static", "jwt" or "unknown"; empty means "unknown".
|
||||
Origin string
|
||||
WrittenBy 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,343 @@
|
||||
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, in.Context)
|
||||
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, cc CaptureContext) (InsightResult, string, error) {
|
||||
note := Note{
|
||||
Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text)),
|
||||
Origin: cc.AuthPath, WrittenBy: cc.Principal,
|
||||
}
|
||||
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,221 @@
|
||||
// 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)
|
||||
in.Context.AuthPath = "jwt"
|
||||
if viaStatic {
|
||||
in.Context.AuthPath = "static"
|
||||
}
|
||||
|
||||
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,51 @@
|
||||
package capturehttp_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// The REST capture door derives AuthPath itself (hyperguild#145); a body
|
||||
// field cannot set it, and static vs JWT are told apart.
|
||||
func TestRESTCaptureStampsAuthPathFromAuthentication(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, authz, wantOrigin, wantBy string
|
||||
v capturehttp.Validator
|
||||
sovereign []string
|
||||
}{
|
||||
{"static", "Bearer " + staticTok, "origin: static\n", "written_by: local-cli\n", nil, nil},
|
||||
{"jwt", "Bearer jwt", "origin: jwt\n", "written_by: koala-cli\n", fakeValidator{subject: "koala-cli"}, []string{"koala-cli"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
cfg, err := classification.Load(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
svc := capture.NewService(brainstore.New(brainDir), fakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||
h := capturehttp.New(svc, tc.v, staticTok, "local-cli", capturehttp.NewOriginResolver(tc.sovereign))
|
||||
rr := do(t, h, tc.authz, map[string]any{
|
||||
"context": map[string]any{
|
||||
"harness": "claude-code", "actor": "mathias", "classification": "internal",
|
||||
"AuthPath": "static", "auth_path": "static", "principal": "local-cli",
|
||||
},
|
||||
"insights": []map[string]any{{"text": "rest provenance probe", "wing": "hyperguild", "hall": "facts"}},
|
||||
})
|
||||
require.Equal(t, 200, rr.Code, rr.Body.String())
|
||||
matches, _ := filepath.Glob(filepath.Join(brainDir, "wiki/hyperguild/facts/*.md"))
|
||||
require.Len(t, matches, 1)
|
||||
b, err := os.ReadFile(matches[0])
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(b), tc.wantOrigin)
|
||||
assert.Contains(t, string(b), tc.wantBy)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package claudewatcher
|
||||
|
||||
import "regexp"
|
||||
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
|
||||
@@ -34,21 +38,94 @@ var DefaultRules = []Rule{
|
||||
// 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/]+@`)},
|
||||
// Any credential embedded in a URL's userinfo, which is what a git credential
|
||||
// store holds: `https://<user>:<token>@host`. The postgres rule above is the
|
||||
// same shape narrowed to one scheme, and is kept ahead of this one only so a
|
||||
// DSN reports the more specific name -- same ordering convention as
|
||||
// authorization-header before bearer-token.
|
||||
//
|
||||
// Added after this shape passed ALL rules and was therefore ingested rather
|
||||
// than dropped (hyperguild#142). It reached ~/.claude/projects -- this
|
||||
// watcher's own input -- because a test suite in mathias/infra executed a
|
||||
// script that configures git while inheriting the operator's real $HOME, read
|
||||
// koala's live credential store, and printed it. Two credentials were rotated
|
||||
// (infra#466). The scrubber is the control that decides whether that class of
|
||||
// mistake is survivable, and for this shape it was failing open.
|
||||
//
|
||||
// The {8,} floor on the secret half is what keeps `https://host:443/path` and
|
||||
// `https://user:@host` out; the `[^:\s/@]+` user half stops a host:port pair
|
||||
// being read as userinfo. Both directions are covered by
|
||||
// TestScrub_URLUserinfoCredential, because a rule that dropped every URL with
|
||||
// a colon in it would discard most of the signal this watcher exists to keep.
|
||||
{Name: "url-userinfo-credential", RE: regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^:\s/@]+:[^@\s/]{8,}@`)},
|
||||
{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`)},
|
||||
{Name: "openai-sk", RE: regexp.MustCompile(`\bsk-(?:proj-)?[A-Za-z0-9]{32,}\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 ""
|
||||
@@ -58,5 +135,12 @@ func Scrub(content string) string {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
extraRulesMu.RLock()
|
||||
defer extraRulesMu.RUnlock()
|
||||
for _, r := range extraRules {
|
||||
if r.RE.MatchString(content) {
|
||||
return r.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ func TestScrub_PoisonedFixtures(t *testing.T) {
|
||||
{"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) {
|
||||
@@ -34,6 +46,64 @@ func TestScrub_PoisonedFixtures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A credential embedded in a URL's userinfo is the shape a git credential store
|
||||
// holds -- `https://<user>:<token>@host` -- and it walked past every rule until
|
||||
// hyperguild#142. There was a rule for `postgres://user:pass@` and nothing for the
|
||||
// general form, so `~/.git-credentials` content was ingested rather than dropped.
|
||||
//
|
||||
// Found the hard way: a test suite in mathias/infra executed a script that
|
||||
// configures git while inheriting the operator's real $HOME, read koala's live
|
||||
// credential store, and printed two live credentials into a session transcript.
|
||||
// ~/.claude/projects is this watcher's own input, so the scrubber was the last
|
||||
// line of defence for that mistake and it failed open. Two credentials were
|
||||
// rotated (infra#466).
|
||||
//
|
||||
// The lesson is not "do not read the credential file" -- that mistake will recur.
|
||||
// It is that this shape must fail closed, because the scrubber is what decides
|
||||
// whether such a mistake is survivable.
|
||||
func TestScrub_URLUserinfoCredential(t *testing.T) {
|
||||
// Every secret below is INVENTED. None is or was a real credential.
|
||||
mustScrub := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"git-credential-store-user", "https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"},
|
||||
{"git-credential-store-oauth2", "https://oauth2:0123456789abcdef0123456789abcdef01234567@git.d-ma.be"},
|
||||
{"in-context-payload-line", `payload line: https://mathias:0123456789abcdef0123456789abcdef01234567@git.d-ma.be`},
|
||||
{"clone-url-in-argv", "git clone https://oauth2:abcdefghijklmnopqrstuvwx@git.d-ma.be/mathias/infra.git"},
|
||||
{"http-not-https", "http://admin:supersecretvalue123@10.0.1.20/api"},
|
||||
{"git-plus-ssh-scheme", "git+ssh://deploy:abcdefghijklmnop@example.com/repo"},
|
||||
{"redis-scheme", "redis://default:abcdefghijklmnopqrst@10.0.1.20:6379/0"},
|
||||
}
|
||||
for _, tc := range mustScrub {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.NotEmpty(t, Scrub(tc.content),
|
||||
"a credential in URL userinfo must be dropped, not ingested")
|
||||
})
|
||||
}
|
||||
|
||||
// The other direction matters as much: a rule that scrubs every URL with a
|
||||
// colon in it would drop most of the signal this watcher exists to keep.
|
||||
mustNotScrub := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"host-with-port", "cloning https://git.d-ma.be:443/mathias/infra.git"},
|
||||
{"postgres-no-password", "postgres://localhost:5432/brain"},
|
||||
{"url-with-colon-in-query", "GET https://example.com/path?filter=a:b&x=1"},
|
||||
{"plain-url", "see https://git.d-ma.be/mathias/infra/issues/466"},
|
||||
{"userinfo-no-secret", "https://user:@example.com"},
|
||||
{"format-string-placeholder", `printf 'https://oauth2:%s@%s\n' "$TOKEN" "$HOST"`},
|
||||
{"scheme-relative-time", "duration https://example.com took 00:03:22"},
|
||||
}
|
||||
for _, tc := range mustNotScrub {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Empty(t, Scrub(tc.content),
|
||||
"this is not a credential and must pass through")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
@@ -43,6 +113,9 @@ func TestScrub_CleanContentPassesThrough(t *testing.T) {
|
||||
"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)
|
||||
@@ -55,3 +128,63 @@ func TestScrub_FirstMatchWins(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -45,6 +45,25 @@ type Config struct {
|
||||
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()
|
||||
@@ -68,6 +87,9 @@ func Watch(ctx context.Context, cfg Config) error {
|
||||
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,
|
||||
@@ -91,7 +113,7 @@ func Watch(ctx context.Context, cfg Config) error {
|
||||
// runTick is one polling pass. Exposed (lowercase) for tests via
|
||||
// TickOnce.
|
||||
func runTick(ctx context.Context, cfg Config) {
|
||||
files, err := listSessionFiles(cfg.SessionsDir)
|
||||
files, err := listSessionFiles(cfg.SessionsDir, cfg.SkipProjects, cfg.Logger)
|
||||
if err != nil {
|
||||
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
||||
return
|
||||
@@ -119,17 +141,52 @@ func TickOnce(ctx context.Context, cfg Config) error {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||
return err
|
||||
}
|
||||
runTick(ctx, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func listSessionFiles(root string) ([]string, error) {
|
||||
// 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") {
|
||||
|
||||
@@ -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.
|
||||
// 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) {
|
||||
ext := strings.ToLower(fileExt(path))
|
||||
switch ext {
|
||||
@@ -20,6 +22,8 @@ func Text(path string) (string, error) {
|
||||
return string(b), nil
|
||||
case ".pdf":
|
||||
return extractPDF(path)
|
||||
case ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||
return extractViaDocmark(path)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file extension: %s", ext)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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 doc's frontmatter block and everything from the closing
|
||||
// fence onward (the closing fence itself excluded, its trailing newline
|
||||
// included), or ok=false if doc has no properly-fenced frontmatter.
|
||||
//
|
||||
// The canonical fence detector for this codebase (hyperguild#138): three
|
||||
// independent reimplementations of this same logic each grew their own gap
|
||||
// through separate bug-fixing before being consolidated here —
|
||||
// internal/mcp's needed a CRLF/BOM guard added after both silently defeated
|
||||
// its own HasPrefix check, and internal/api's closing-fence match had no
|
||||
// trailing-newline requirement, so a document ending in "\n---extra" with no
|
||||
// real newline after the fence was treated as validly closed. Both classes
|
||||
// are closed here, once, for every caller.
|
||||
func Split(doc []byte) (block, rest []byte, ok bool) {
|
||||
s := strings.TrimPrefix(string(doc), "\xef\xbb\xbf")
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
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
|
||||
}
|
||||
// What follows the closing fence must be either a newline (a real body
|
||||
// follows) or nothing at all (the fence is the literal end of the
|
||||
// document — no body, not a bypass attempt). Anything else, e.g.
|
||||
// "\n---extra", is not a valid close: an earlier version of this check
|
||||
// accepted it, which is exactly the looser match hyperguild#138 closes.
|
||||
after := idx + 1 + len(fence)
|
||||
switch {
|
||||
case after == len(body):
|
||||
return []byte(body[:idx+1]), nil, true
|
||||
case body[after] == '\n':
|
||||
return []byte(body[:idx+1]), []byte(body[after:]), true
|
||||
default:
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// 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")
|
||||
}
|
||||
|
||||
// TestSplit_NormalizesCRLFBeforeFenceDetection is hyperguild#138: a note
|
||||
// whose fence lines use CRLF ("---\r\n") failed strings.HasPrefix(doc,
|
||||
// "---\n") on the precondition read in internal/mcp's independent
|
||||
// reimplementation -- encoding alone, no adversarial input, silently
|
||||
// defeated fence detection there before this consolidation. Split must
|
||||
// never repeat that.
|
||||
func TestSplit_NormalizesCRLFBeforeFenceDetection(t *testing.T) {
|
||||
doc := "---\r\ntitle: 'n'\r\nhalf_life: durable\r\n---\r\n\r\nbody\r\n"
|
||||
block, rest, ok := Split([]byte(doc))
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, string(block), "half_life: durable")
|
||||
assert.Contains(t, string(rest), "body")
|
||||
}
|
||||
|
||||
// TestSplit_StripsALeadingUTF8BOM is the same mechanism one layer earlier:
|
||||
// a leading BOM (Notepad's default "UTF-8" save, PowerShell's pre-v6
|
||||
// Out-File/Set-Content) defeats the same HasPrefix check before CRLF
|
||||
// normalization even applies.
|
||||
func TestSplit_StripsALeadingUTF8BOM(t *testing.T) {
|
||||
doc := "\xef\xbb\xbf---\ntitle: 'n'\n---\n\nbody\n"
|
||||
block, rest, ok := Split([]byte(doc))
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, string(block), "title: 'n'")
|
||||
assert.Contains(t, string(rest), "body")
|
||||
}
|
||||
|
||||
// TestSplit_RequiresANewlineAfterTheClosingFence closes the looser match
|
||||
// internal/api's independent reimplementation carried (strings.Index(body,
|
||||
// "\n"+fence) with no trailing-newline requirement): a document ending in
|
||||
// "\n---extra" with no newline separating the fence from what follows it is
|
||||
// not a valid closing fence and must not be treated as one.
|
||||
func TestSplit_RequiresANewlineAfterTheClosingFence(t *testing.T) {
|
||||
doc := "---\ntitle: 'n'\n---extra-not-a-real-close"
|
||||
_, _, ok := Split([]byte(doc))
|
||||
assert.False(t, ok, "a fence line not followed by a newline is not a valid closing fence")
|
||||
}
|
||||
|
||||
// TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocument is a
|
||||
// regression pin found in review of hyperguild#138: the fix for the
|
||||
// "\n---extra" bypass above must not also reject the legitimate case of a
|
||||
// closing fence with nothing after it at all (no trailing newline because
|
||||
// there's no body, not because a bypass is being attempted) -- this
|
||||
// package's own private split() accepted that shape before the
|
||||
// consolidation, and cmd/frontmatterlint's Lint/Fix relied on it.
|
||||
func TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocument(t *testing.T) {
|
||||
doc := "---\ntitle: 'n'\n---"
|
||||
block, rest, ok := Split([]byte(doc))
|
||||
require.True(t, ok, "a closing fence at literal end-of-file, with nothing after it, is valid -- there is no body to require a newline before")
|
||||
assert.Contains(t, string(block), "title: 'n'")
|
||||
assert.Equal(t, "", string(rest))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -5,14 +5,18 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Router calls Primary first; on any error falls back to Fallback.
|
||||
// Fallback may be nil, in which case primary errors are returned directly.
|
||||
// Router calls Primary first; on any error falls back to Fallback, then to
|
||||
// Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
|
||||
// the chain returns the errors accumulated so far.
|
||||
type Router struct {
|
||||
Primary *Client
|
||||
Fallback *Client
|
||||
Tertiary *Client
|
||||
}
|
||||
|
||||
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
||||
// Complete implements pipeline.CompleteFunc, routing through Primary, then
|
||||
// Fallback, then Tertiary — trying each configured tier in order and
|
||||
// returning the first success.
|
||||
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
out, err := r.Primary.Complete(ctx, system, user)
|
||||
if err == nil {
|
||||
@@ -22,8 +26,15 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
|
||||
return "", fmt.Errorf("primary llm: %w", err)
|
||||
}
|
||||
out, err2 := r.Fallback.Complete(ctx, system, user)
|
||||
if err2 != nil {
|
||||
if err2 == nil {
|
||||
return out, nil
|
||||
}
|
||||
if r.Tertiary == nil {
|
||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
||||
}
|
||||
out, err3 := r.Tertiary.Complete(ctx, system, user)
|
||||
if err3 != nil {
|
||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v; tertiary llm: %v", err, err2, err3)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -69,3 +69,78 @@ func TestRouter_NilFallback(t *testing.T) {
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer fail.Close()
|
||||
tertiary := mockServer(t, "from-tertiary")
|
||||
defer tertiary.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-tertiary", out)
|
||||
}
|
||||
|
||||
func TestRouter_TertiaryNotCalledWhenFallbackSucceeds(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer fail.Close()
|
||||
fallback := mockServer(t, "from-fallback")
|
||||
defer fallback.Close()
|
||||
tertiary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("tertiary must not be called when fallback succeeds")
|
||||
}))
|
||||
defer tertiary.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-fallback", out)
|
||||
}
|
||||
|
||||
func TestRouter_AllThreeFail(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
Tertiary: New(fail.URL, "", "m", time.Second),
|
||||
}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "primary llm")
|
||||
assert.Contains(t, err.Error(), "fallback llm")
|
||||
assert.Contains(t, err.Error(), "tertiary llm")
|
||||
}
|
||||
|
||||
func TestRouter_NilTertiary(t *testing.T) {
|
||||
// Existing 2-tier deployments (no BRAIN_LLM_TERTIARY_URL) must keep working
|
||||
// exactly as before — Tertiary is opt-in, not required.
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "tertiary")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// logToolCall emits one structured record per tools/call (hyperguild#144,
|
||||
// OWASP MCP08): who called which tool, against which wing/hall, and how it
|
||||
// ended. Brain content is never copied into the record — only its size — so
|
||||
// the audit trail does not become a second, unscrubbed copy of the brain.
|
||||
func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) {
|
||||
origin, principal := provenanceFrom(ctx)
|
||||
attrs := []any{
|
||||
"tool", name,
|
||||
"principal", principal,
|
||||
"origin", origin,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
}
|
||||
var a struct {
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(args, &a) == nil {
|
||||
if a.Wing != "" {
|
||||
attrs = append(attrs, "wing", a.Wing)
|
||||
}
|
||||
if a.Hall != "" {
|
||||
attrs = append(attrs, "hall", a.Hall)
|
||||
}
|
||||
if a.Content != "" {
|
||||
attrs = append(attrs, "content_bytes", len(a.Content))
|
||||
}
|
||||
}
|
||||
// The outcome is a class, never the error text: handler errors echo
|
||||
// argument values (a rejected half_life, a requested path), which would
|
||||
// make the audit line a copy of what the caller sent (PR #147 review E6).
|
||||
// The caller already received the full error in the tool response.
|
||||
outcome := "ok"
|
||||
if err != nil {
|
||||
outcome = "error"
|
||||
}
|
||||
attrs = append(attrs, "outcome", outcome)
|
||||
slog.InfoContext(ctx, "mcp tool call", attrs...)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// captureToolCallLog routes slog to a buffer for fn and returns the decoded
|
||||
// "mcp tool call" records.
|
||||
func captureToolCallLog(t *testing.T, fn func()) []map[string]any {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
fn()
|
||||
var out []map[string]any
|
||||
for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||
var rec map[string]any
|
||||
if json.Unmarshal([]byte(l), &rec) == nil && rec["msg"] == "mcp tool call" {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hyperguild#144: brain_query/brain_read left no record of who read what.
|
||||
func TestToolsCallLogsOneRecordWithToolPrincipalOriginAndScope(t *testing.T) {
|
||||
srv, _, _, _ := govServer(t)
|
||||
recs := captureToolCallLog(t, func() {
|
||||
authCall(t, srv, "Bearer "+capStaticTok, "brain_query",
|
||||
map[string]any{"query": "anything", "wing": "homelab", "hall": "facts"})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
r := recs[0]
|
||||
assert.Equal(t, "brain_query", r["tool"])
|
||||
assert.Equal(t, "local-cli", r["principal"])
|
||||
assert.Equal(t, "static", r["origin"])
|
||||
assert.Equal(t, "homelab", r["wing"])
|
||||
assert.Equal(t, "facts", r["hall"])
|
||||
assert.Equal(t, "ok", r["outcome"])
|
||||
assert.Contains(t, r, "duration_ms")
|
||||
}
|
||||
|
||||
func TestToolsCallWithoutDerivablePrincipalLogsOriginUnknown(t *testing.T) {
|
||||
srv, _, _, _ := govServer(t)
|
||||
recs := captureToolCallLog(t, func() {
|
||||
authCall(t, srv, "", "brain_query", map[string]any{"query": "x"})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
assert.Equal(t, "unknown", recs[0]["origin"])
|
||||
assert.Equal(t, "", recs[0]["principal"])
|
||||
}
|
||||
|
||||
func TestToolsCallLogsErrorOutcome(t *testing.T) {
|
||||
srv, _, _, _ := govServer(t)
|
||||
recs := captureToolCallLog(t, func() {
|
||||
authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
assert.Equal(t, "error", recs[0]["outcome"])
|
||||
}
|
||||
|
||||
// The audit line must not become a second copy of brain content.
|
||||
func TestToolsCallLogOmitsContentButRecordsItsSize(t *testing.T) {
|
||||
const sentinel = "SENTINEL-BRAIN-CONTENT-91c2"
|
||||
srv, _, _, _ := govServer(t)
|
||||
recs := captureToolCallLog(t, func() {
|
||||
authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{
|
||||
"content": "# t\n\n" + sentinel, "wing": "testing", "hall": "facts", "filename": "audit-sentinel",
|
||||
})
|
||||
})
|
||||
require.Len(t, recs, 1)
|
||||
raw, _ := json.Marshal(recs[0])
|
||||
assert.NotContains(t, string(raw), sentinel)
|
||||
assert.EqualValues(t, len("# t\n\n"+sentinel), recs[0]["content_bytes"])
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// callResult parses the JSON text payload of a successful tool call.
|
||||
func callResult(t *testing.T, resp map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
require.Nil(t, resp["error"], "tool returned error: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &out))
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBrainUpdateSupersedesExisting(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
// Seed via brain_write so the note carries real frontmatter.
|
||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# Old\n\nold body\n", "filename": "val-vol",
|
||||
"wing": "jepa-fx", "hall": "facts",
|
||||
}))
|
||||
|
||||
out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{
|
||||
"wing": "jepa-fx", "hall": "facts", "slug": "val-vol",
|
||||
"content": "# New\n\nnew body\n", "reason": "facts changed",
|
||||
}))
|
||||
assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", out["path"])
|
||||
assert.Equal(t, out["path"], out["id"])
|
||||
assert.NotEmpty(t, out["content_hash"])
|
||||
assert.Equal(t, true, out["superseded"])
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, "wiki/jepa-fx/facts/val-vol.md"))
|
||||
require.NoError(t, err)
|
||||
s := string(got)
|
||||
assert.Contains(t, s, "# New")
|
||||
assert.NotContains(t, s, "old body")
|
||||
assert.Contains(t, s, "wing: jepa-fx")
|
||||
assert.Contains(t, s, "supersede_reason: facts changed")
|
||||
assert.Contains(t, s, "supersedes:")
|
||||
}
|
||||
|
||||
func TestBrainUpdateMissingTargetErrorsNoCreate(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_update", map[string]any{
|
||||
"wing": "jepa-fx", "hall": "facts", "slug": "ghost",
|
||||
"content": "x\n",
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "does not exist")
|
||||
_, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
}
|
||||
|
||||
func TestBrainUpdateByFullPath(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "old\n", "filename": "n", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
|
||||
out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{
|
||||
"slug": "wiki/a/facts/n.md", "content": "fresh\n",
|
||||
}))
|
||||
assert.Equal(t, "wiki/a/facts/n.md", out["path"])
|
||||
}
|
||||
|
||||
func TestBrainGetByIDAndPath(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
w := callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# Body\n\ntext\n", "filename": "n", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
id := w["id"].(string)
|
||||
hash := w["content_hash"].(string)
|
||||
require.NotEmpty(t, id)
|
||||
require.NotEmpty(t, hash)
|
||||
|
||||
// by id
|
||||
g1 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"id": id}))
|
||||
assert.Equal(t, id, g1["path"])
|
||||
assert.Equal(t, hash, g1["content_hash"], "content_hash must round-trip write→get")
|
||||
assert.Contains(t, g1["body"].(string), "# Body")
|
||||
fm := g1["frontmatter"].(map[string]any)
|
||||
assert.Equal(t, "a", fm["wing"])
|
||||
|
||||
// by path
|
||||
g2 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"path": id}))
|
||||
assert.Equal(t, hash, g2["content_hash"])
|
||||
}
|
||||
|
||||
func TestBrainGetMissingArgsErrors(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_get", map[string]any{})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainWriteReturnsHandle(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
out := callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# X\n\nbody\n", "filename": "x", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
assert.Equal(t, "wiki/a/facts/x.md", out["path"])
|
||||
assert.Equal(t, out["path"], out["id"])
|
||||
assert.NotEmpty(t, out["content_hash"])
|
||||
}
|
||||
|
||||
// --- retrieval-reflects-new-content: exercises the real mtime-driven Sync ---
|
||||
|
||||
type fakeVecStore struct {
|
||||
chunks map[string][]float32
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeVecStore) KnownPathsWithTime(_ context.Context) (map[string]time.Time, error) {
|
||||
m := make(map[string]time.Time, len(f.chunks))
|
||||
for p := range f.chunks {
|
||||
m[p] = time.Unix(0, 0) // always stale → mtime(now) is always newer
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (f *fakeVecStore) Upsert(_ context.Context, path string, vec []float32) error {
|
||||
f.chunks[path] = vec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeVecStore) Delete(_ context.Context, path string) error {
|
||||
delete(f.chunks, path)
|
||||
f.deleted = append(f.deleted, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEmbedder struct{ seen []string }
|
||||
|
||||
func (e *fakeEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
|
||||
e.seen = append(e.seen, text)
|
||||
return []float32{1, 0, 0}, nil
|
||||
}
|
||||
|
||||
// TestBrainUpdateReembedsNewContent proves the supersede contract end to
|
||||
// end against the actual embedding mechanism: brain_update rewrites the
|
||||
// file, advancing its mtime, and the next vectorstore.Sync pass re-embeds
|
||||
// the NEW body and drops the stale chunk. No stub of the re-index path.
|
||||
func TestBrainUpdateReembedsNewContent(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# Note\n\nthe OLD distinctive payload\n",
|
||||
"filename": "n", "wing": "a", "hall": "facts",
|
||||
}))
|
||||
|
||||
store := &fakeVecStore{chunks: map[string][]float32{}}
|
||||
emb := &fakeEmbedder{}
|
||||
|
||||
// First sync embeds the original content.
|
||||
_, err := vectorstore.Sync(ctx, brainDir, store, emb)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, store.chunks)
|
||||
require.True(t, anyContains(emb.seen, "OLD distinctive payload"))
|
||||
|
||||
callResult(t, toolCall(t, srv, "brain_update", map[string]any{
|
||||
"wing": "a", "hall": "facts", "slug": "n",
|
||||
"content": "# Note\n\nthe NEW distinctive payload\n",
|
||||
}))
|
||||
|
||||
emb.seen = nil // only watch what the second pass embeds
|
||||
_, err = vectorstore.Sync(ctx, brainDir, store, emb)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, anyContains(emb.seen, "NEW distinctive payload"),
|
||||
"Sync must re-embed the superseded body; saw %v", emb.seen)
|
||||
assert.False(t, anyContains(emb.seen, "OLD distinctive payload"),
|
||||
"the old body must not be re-embedded")
|
||||
assert.NotEmpty(t, store.deleted, "stale chunks must be deleted before re-embed")
|
||||
}
|
||||
|
||||
func anyContains(ss []string, sub string) bool {
|
||||
for _, s := range ss {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -11,9 +11,12 @@ import (
|
||||
|
||||
"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/extract"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||
)
|
||||
@@ -38,7 +41,7 @@ func (s *Server) tools() []map[string]any {
|
||||
return b
|
||||
}
|
||||
|
||||
return []map[string]any{
|
||||
tools := []map[string]any{
|
||||
{
|
||||
"name": "brain_query",
|
||||
"description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).",
|
||||
@@ -51,16 +54,59 @@ func (s *Server) tools() []map[string]any {
|
||||
},
|
||||
{
|
||||
"name": "brain_write",
|
||||
"description": "Write a markdown note to the brain. With wing+hall set, routes to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter; otherwise writes to brain/knowledge/ (legacy).",
|
||||
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||
"description": "Write a markdown note to brain/wiki/<wing>/<hall>/ with wing/hall/created_at frontmatter. Both wing and hall are required.",
|
||||
"inputSchema": schema([]string{"content", "wing", "hall"}, map[string]any{
|
||||
"content": str("markdown content"),
|
||||
"filename": str("optional filename or slug"),
|
||||
"type": str("optional frontmatter type (legacy)"),
|
||||
"domain": str("optional frontmatter domain (legacy)"),
|
||||
"wing": str("optional topic domain, e.g. jepa-fx"),
|
||||
"hall": enum("optional memory type (requires wing)", halls...),
|
||||
"wing": str("topic domain, e.g. jepa-fx"),
|
||||
"hall": enum("memory type", halls...),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_update",
|
||||
"description": "Supersede an existing brain note in place: whole-note body replace + frontmatter re-stamp (updated_at, supersedes=prior content hash, supersede_reason). Errors if the target does not exist — use brain_write to create. Returns {id, path, content_hash, superseded}. Prior version recoverable from git.",
|
||||
"inputSchema": schema([]string{"content"}, map[string]any{
|
||||
"content": str("new full body (whole-note replace)"),
|
||||
"slug": str("target note slug within wing/hall, OR a full brain-relative path (e.g. wiki/jepa-fx/facts/x.md)"),
|
||||
"wing": str("wing of the target (required unless slug/path is a full path)"),
|
||||
"hall": enum("hall of the target (required unless slug/path is a full path)", halls...),
|
||||
"path": str("full brain-relative path to the target; takes precedence over slug/wing/hall"),
|
||||
"reason": str("optional short note on why superseded — stamped into frontmatter"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_get",
|
||||
"description": "Fetch a single brain note by id or path (both are the brain-relative path — the note handle). Returns {id, path, content_hash, frontmatter, body}. Read-after-write confirmation without a lexical re-query.",
|
||||
"inputSchema": schema([]string{}, map[string]any{
|
||||
"id": str("note id (brain-relative path) as returned by brain_write/brain_update"),
|
||||
"path": str("brain-relative path to the note; equivalent to id"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_pending",
|
||||
"description": "List notes in brain/raw/ awaiting human promotion to the wiki, oldest-first. Returns filename, created_at, size_bytes, excerpt. The human-review queue complement to brain_promote.",
|
||||
"inputSchema": schema([]string{}, map[string]any{}),
|
||||
},
|
||||
{
|
||||
"name": "brain_promote",
|
||||
"description": "Promote a brain/raw/ note into brain/wiki/<wing>/<hall>/: rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at + custom fields), deletes the source, rebuilds the wing index, runs auto-tunnel. Errors (without touching the fs) on invalid hall or a slug collision. Returns {path}.",
|
||||
"inputSchema": schema([]string{"filename", "wing", "hall"}, map[string]any{
|
||||
"filename": str("basename in brain/raw/, e.g. 2026-06-01-lejpa-decision.md"),
|
||||
"wing": str("target wing, e.g. jepa-fx"),
|
||||
"hall": enum("target hall", halls...),
|
||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_reject",
|
||||
"description": "Reject a brain/raw/ note: stamps rejected_at (+ reject_reason if given) into frontmatter and moves it to brain/raw/rejected/ — never deleted outright, and never re-surfaced by brain_pending. The third option alongside promote/leave-pending; a human decision, never automatic. Returns {path}.",
|
||||
"inputSchema": schema([]string{"filename"}, map[string]any{
|
||||
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
||||
"reason": str("optional; why this isn't worth promoting"),
|
||||
}),
|
||||
},
|
||||
noteToolDescriptors()[0],
|
||||
noteToolDescriptors()[1],
|
||||
{
|
||||
"name": "brain_tunnel",
|
||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||
@@ -69,13 +115,6 @@ func (s *Server) tools() []map[string]any {
|
||||
"target": str("path of target note (must be in a different wing)"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_index",
|
||||
"description": "Regenerate _index.md (Map of Content) for one or all wings under brain/wiki/. Auto-called after brain_write with wing+hall.",
|
||||
"inputSchema": schema([]string{}, map[string]any{
|
||||
"wing": str("optional wing to index; if absent, rebuilds every wing"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_ingest_raw",
|
||||
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
|
||||
@@ -151,6 +190,13 @@ func (s *Server) tools() []map[string]any {
|
||||
}),
|
||||
},
|
||||
}
|
||||
// The capture relay tool (#55) is advertised only when wired via
|
||||
// WithCapture — MCP-native harnesses (claude.ai, Crush, Pi, LLM Council)
|
||||
// reach capture through it.
|
||||
if s.capture != nil {
|
||||
tools = append(tools, captureToolDescriptor())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
type brainQueryArgs struct {
|
||||
@@ -188,40 +234,195 @@ func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
type brainWriteArgs struct {
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
}
|
||||
|
||||
// unlinkedNote explains the `unlinked` field to the caller that caused it.
|
||||
// The write landed; what is gone is the [[...]] around a target that names no
|
||||
// brain entry. Said here rather than only in a doc comment because the caller
|
||||
// is a model that will otherwise repeat it on the next write.
|
||||
const unlinkedNote = "these [[wikilink]] targets resolve to no brain entry, " +
|
||||
"so they were unwrapped to plain text. Link only entries that exist " +
|
||||
"(a filename under knowledge/ or wiki/); write the target entry first if " +
|
||||
"you want the link, and keep skills, repos and agent-local memory files as prose."
|
||||
|
||||
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainWriteArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Content: a.Content,
|
||||
Filename: a.Filename,
|
||||
Type: a.Type,
|
||||
Domain: a.Domain,
|
||||
Wing: a.Wing,
|
||||
Hall: a.Hall,
|
||||
// Delegate to the shared BrainStore so write+index+tunnel+graph live in
|
||||
// one implementation (capture uses the same store). The read-after-write
|
||||
// handle {id, path, content_hash} comes back from the store; path is kept
|
||||
// for backward compatibility.
|
||||
origin, writtenBy := provenanceFrom(ctx)
|
||||
ref, err := s.store.Write(ctx, capture.Note{
|
||||
Content: a.Content,
|
||||
Filename: a.Filename,
|
||||
Wing: a.Wing,
|
||||
Hall: a.Hall,
|
||||
Origin: origin,
|
||||
WrittenBy: writtenBy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Auto-regenerate the wing _index.md when the write landed in the
|
||||
// structured wiki, and auto-tunnel cross-wing matches. Both are
|
||||
// best-effort: the note is already written.
|
||||
if a.Wing != "" && a.Hall != "" {
|
||||
if err := brain.BuildWingIndex(s.brainDir, a.Wing); err != nil {
|
||||
slog.Warn("brain_write: auto-index failed", "wing", a.Wing, "err", err)
|
||||
}
|
||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
||||
slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err)
|
||||
}
|
||||
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}
|
||||
if len(ref.Unlinked) > 0 {
|
||||
out["unlinked"] = ref.Unlinked
|
||||
out["unlinked_note"] = unlinkedNote
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
type brainUpdateArgs struct {
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Wing string `json:"wing,omitempty"`
|
||||
Hall string `json:"hall,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// brainUpdate supersedes an existing note in place: whole-note body
|
||||
// replace, frontmatter re-stamp (updated_at/supersedes/supersede_reason),
|
||||
// graph re-index, and wing _index rebuild. It never creates — a missing
|
||||
// target is an error so the caller can fall back to brain_write.
|
||||
//
|
||||
// Embedding re-sync is delegated to the out-of-band vectorstore.Sync
|
||||
// ticker: the rewritten file's mtime advances, so the next pass re-embeds
|
||||
// it. This mirrors brain_write, which likewise does not embed in-handler.
|
||||
func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainUpdateArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
if a.Content == "" {
|
||||
return nil, fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
// path takes precedence over slug; the store treats any slug containing
|
||||
// a slash as a full brain-relative path (issue #45: "slug ... OR path").
|
||||
slug := a.Slug
|
||||
if a.Path != "" {
|
||||
slug = a.Path
|
||||
}
|
||||
origin, writtenBy := provenanceFrom(ctx)
|
||||
ref, err := s.store.Update(ctx, slug, capture.Note{
|
||||
Content: a.Content,
|
||||
Wing: a.Wing,
|
||||
Hall: a.Hall,
|
||||
Reason: a.Reason,
|
||||
Origin: origin,
|
||||
WrittenBy: writtenBy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||
}
|
||||
if len(ref.Unlinked) > 0 {
|
||||
out["unlinked"] = ref.Unlinked
|
||||
out["unlinked_note"] = unlinkedNote
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
type brainGetArgs struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// brainGet fetches a note by id or path (both are the brainDir-relative
|
||||
// path — the de-facto handle). Read-only; the create-path read-after-
|
||||
// write primitive that lets callers confirm a write landed without a
|
||||
// lexical re-query.
|
||||
func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainGetArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
target := a.Path
|
||||
if target == "" {
|
||||
target = a.ID
|
||||
}
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("id or path is required")
|
||||
}
|
||||
// Containment: the store resolves any brain-relative path, and
|
||||
// parseFrontmatter returns the WHOLE file as body when there is no
|
||||
// frontmatter fence — so without this, brain_get reads verbatim session
|
||||
// transcripts under archive/ and sessions/, which BM25 never indexes and
|
||||
// which nobody therefore thinks of as reachable. Same allowlist as
|
||||
// brain_read (brain#4).
|
||||
if _, err := notes.Resolve(s.brainDir, target, notes.ScopeNotes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
note, err := s.store.Get(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"id": note.ID, "path": note.Path, "content_hash": note.ContentHash,
|
||||
"frontmatter": note.Frontmatter, "body": note.Body,
|
||||
})
|
||||
}
|
||||
|
||||
// brainPending lists the raw/ review queue (oldest-first).
|
||||
func (s *Server) brainPending(_ context.Context, _ json.RawMessage) (json.RawMessage, error) {
|
||||
pending, err := api.ListPending(s.brainDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"pending": pending})
|
||||
}
|
||||
|
||||
type brainPromoteArgs struct {
|
||||
Filename string `json:"filename"`
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
}
|
||||
|
||||
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||
// rewrite + index + auto-tunnel, all owned by api.PromoteNote) and then
|
||||
// re-indexes it into the graph. The human-facing complement to brain_write.
|
||||
func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainPromoteArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
origin, writtenBy := provenanceFrom(ctx)
|
||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
||||
Origin: origin, WrittenBy: writtenBy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_promote", relPath)
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
type brainRejectArgs struct {
|
||||
Filename string `json:"filename"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
||||
// frontmatter-stamp + move to raw/rejected/). No graph indexing — a rejected
|
||||
// note never entered the wiki, so there's nothing to index.
|
||||
func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainRejectArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
@@ -299,7 +500,7 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
|
||||
if len(a.Pages) == 0 {
|
||||
return nil, fmt.Errorf("pages must be non-empty")
|
||||
}
|
||||
result, err := pipeline.RunRaw(s.brainDir, a.Source, a.Pages, a.DryRun)
|
||||
result, err := pipeline.RunRaw(withCallerStamp(ctx), s.brainDir, a.Source, a.Pages, a.DryRun)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingest: %w", err)
|
||||
}
|
||||
@@ -398,7 +599,7 @@ func (s *Server) sessionLog(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
}
|
||||
|
||||
func (s *Server) runIngest(ctx context.Context, content, source string, dryRun bool) (json.RawMessage, error) {
|
||||
result, err := pipeline.Run(ctx, s.pipeline, s.brainDir, content, source, dryRun)
|
||||
result, err := pipeline.Run(withCallerStamp(ctx), s.pipeline, s.brainDir, content, source, dryRun)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ingest: %w", err)
|
||||
}
|
||||
@@ -417,3 +618,10 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
|
||||
}
|
||||
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
||||
}
|
||||
|
||||
// withCallerStamp attaches the caller's provenance for the ingest pipeline,
|
||||
// which stamps every page it writes or merges into (hyperguild#145).
|
||||
func withCallerStamp(ctx context.Context) context.Context {
|
||||
origin, writtenBy := provenanceFrom(ctx)
|
||||
return provenance.WithStamp(ctx, provenance.Stamp{Origin: origin, WrittenBy: writtenBy})
|
||||
}
|
||||
|
||||
@@ -51,25 +51,50 @@ func TestBrainQueryReturnsResults(t *testing.T) {
|
||||
assert.Contains(t, text, "tdd.md")
|
||||
}
|
||||
|
||||
func TestBrainWriteCreatesFile(t *testing.T) {
|
||||
// TestBrainWriteIgnoresLegacyTypeAndDomainParams pins the post-#79 contract:
|
||||
// domain/type were removed from the schema and the handler's arg struct, so
|
||||
// a caller that still sends them (the old MCP surface) gets a normal wing+
|
||||
// hall write with no error and no effect from the stale fields — silently
|
||||
// dropped by json.Unmarshal, not rejected. Only content in its OWN
|
||||
// frontmatter (unrelated to these params) can set type/domain, per
|
||||
// TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking in the
|
||||
// api package.
|
||||
func TestBrainWriteIgnoresLegacyTypeAndDomainParams(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "# Test\n\nbody",
|
||||
"filename": "test.md",
|
||||
"filename": "test",
|
||||
"wing": "homelab",
|
||||
"hall": "facts",
|
||||
"type": "note",
|
||||
"domain": "personal",
|
||||
})
|
||||
require.Nil(t, resp["error"])
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, "knowledge", "test.md"))
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, "wiki", "homelab", "facts", "test.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "type: note")
|
||||
assert.Contains(t, string(got), "domain: personal")
|
||||
assert.NotContains(t, string(got), "type: note")
|
||||
assert.NotContains(t, string(got), "domain: personal")
|
||||
assert.Contains(t, string(got), "# Test")
|
||||
}
|
||||
|
||||
// TestBrainWriteRequiresWingAndHall is the MCP-tool-call counterpart of
|
||||
// api.TestWriteNote_RequiresWingAndHall: the legacy brain/knowledge/
|
||||
// fallback is gone, so a caller omitting either param gets a tool error,
|
||||
// not a silent legacy write.
|
||||
func TestBrainWriteRequiresWingAndHall(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "x",
|
||||
"filename": "no-destination",
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainWriteWingHallRoutesToWiki(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
@@ -208,30 +233,11 @@ func TestBrainTunnelRejectsMissing(t *testing.T) {
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainWriteRejectsTraversal(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "x",
|
||||
"filename": "../escape.md",
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_write", map[string]any{
|
||||
"content": "x",
|
||||
"filename": "notes..draft.md",
|
||||
})
|
||||
require.Nil(t, resp["error"])
|
||||
|
||||
_, err := os.Stat(filepath.Join(brainDir, "knowledge", "notes..draft.md"))
|
||||
require.NoError(t, err, "filename with embedded .. should be allowed")
|
||||
}
|
||||
// Filename traversal/sanitisation on the wiki route is covered directly
|
||||
// against brain.NotePath/Sanitise in internal/brain/path_test.go
|
||||
// (TestNotePath_SanitisesWingAndSlug, TestSanitise) — those tests exercised
|
||||
// the now-deleted brain/knowledge/ legacy path's own separate filename
|
||||
// validation, which had no equivalent once that path was removed (#79).
|
||||
|
||||
func TestBrainIngestRawDryRun(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
@@ -332,3 +338,96 @@ func TestSessionLogRequiresSessionID(t *testing.T) {
|
||||
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainPendingListsRaw(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-x.md"),
|
||||
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\npending body\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
require.Nil(t, resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "2026-06-01-x.md")
|
||||
assert.Contains(t, text, "pending body")
|
||||
}
|
||||
|
||||
func TestBrainPendingEmpty(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
require.Nil(t, resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, `"pending":[]`)
|
||||
}
|
||||
|
||||
func TestBrainPromoteMovesToWiki(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-decision.md"),
|
||||
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\n# D\n\nbody\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||
"filename": "2026-06-01-decision.md", "wing": "jepa-fx", "hall": "decisions",
|
||||
})
|
||||
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "wiki/jepa-fx/decisions/decision.md")
|
||||
|
||||
_, err := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/decisions/decision.md"))
|
||||
require.NoError(t, err)
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "2026-06-01-decision.md"))
|
||||
assert.True(t, os.IsNotExist(srcErr), "source removed")
|
||||
}
|
||||
|
||||
func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "x.md"), []byte("body\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||
"filename": "x.md", "wing": "a", "hall": "garbage",
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
||||
assert.NoError(t, srcErr, "source untouched on validation error")
|
||||
}
|
||||
|
||||
func TestBrainRejectMovesToRejectedSubdir(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-04-23-scrape.md"),
|
||||
[]byte("---\ncreated_at: 2026-04-23T13:20:07Z\n---\n# Scrape\n\nbody\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_reject", map[string]any{
|
||||
"filename": "2026-04-23-scrape.md", "reason": "generic scrape, not homelab-relevant",
|
||||
})
|
||||
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "raw/rejected/2026-04-23-scrape.md")
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(brainDir, "raw/rejected/2026-04-23-scrape.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "reject_reason: generic scrape, not homelab-relevant")
|
||||
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "2026-04-23-scrape.md"))
|
||||
assert.True(t, os.IsNotExist(srcErr), "source removed from raw/")
|
||||
|
||||
// A rejected note must not still show up as pending.
|
||||
pendingResp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
pendingText := pendingResp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, pendingText, `"pending":[]`)
|
||||
}
|
||||
|
||||
func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user