Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ac165cca3 | ||
|
|
43f92e3102 | ||
|
|
98cfae595c | ||
|
|
2a595b5a92 | ||
|
|
b7a2cc5fdf | ||
|
|
db638cca11 | ||
|
|
38579598e0 | ||
|
|
d6fa92b176 | ||
|
|
9173f9058d | ||
|
|
3e84a41fed | ||
|
|
0e0571c7da | ||
|
|
7a27cf71a2 | ||
|
|
63df6d3283 |
@@ -1,361 +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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## Default stack
|
||||
|
||||
| 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
|
||||
|
||||
## 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:
|
||||
|
||||
| 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://git.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 at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**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` |
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
-364
@@ -1,364 +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.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
- **Tag + bump SemVer** on the change (annotated tag; minor for a feature or
|
||||
new/changed ADR, patch for a fix; docs in the same commit). Check the repo's
|
||||
actual last tag — stated versions in docs drift stale.
|
||||
- **Push** main and the tag (CI is the gate).
|
||||
- **Persist generalizable learnings to the brain** (`brain_write`, wing/hall) —
|
||||
the reusable patterns and the footguns that would bite anyone again, never
|
||||
project status. See *Knowledge base — when to write* below.
|
||||
- **File discovered-but-deferred work as tracker issues** on the project's own
|
||||
repo — token-budget gaps, recorded ADR limitations, v2 follow-ups. Don't let
|
||||
"out of scope, recorded" rot in a commit message; make it a ticket with a
|
||||
source pointer.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
## Default stack
|
||||
|
||||
| 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
|
||||
|
||||
## 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:
|
||||
|
||||
| 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://git.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 at task start — not "on demand" but on schedule, before writing code. See `~/dev/.skills/SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**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` |
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
+2
-4
@@ -17,8 +17,6 @@ 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -90,12 +88,12 @@ tasks:
|
||||
cmds:
|
||||
- task: context:sync
|
||||
- cmd: |
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||
if [ -n "$drift" ]; then
|
||||
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 "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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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) {
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Content: n.Content,
|
||||
Filename: n.Filename,
|
||||
Type: n.Type,
|
||||
Domain: n.Domain,
|
||||
Wing: n.Wing,
|
||||
Hall: n.Hall,
|
||||
})
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
s.wikiUpkeep(relPath, n.Wing, n.Content)
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
|
||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, 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) {
|
||||
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||
if strings.Contains(slug, "/") {
|
||||
opts.Path = slug
|
||||
} else {
|
||||
opts.Wing, opts.Hall, opts.Slug = n.Wing, n.Hall, slug
|
||||
}
|
||||
|
||||
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
if wing := wingFromRelPath(relPath); wing != "" {
|
||||
s.wikiUpkeep(relPath, wing, n.Content)
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_update", relPath)
|
||||
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, 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,85 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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,
|
||||
// optional summary → ai-sessions — 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
|
||||
|
||||
// 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 is server-derived from the
|
||||
// authenticated identity (#53 populates it); it is 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Summary is an optional session summary bound for ai-sessions.
|
||||
type Summary struct {
|
||||
Title string
|
||||
Body string
|
||||
ReposTouched []string
|
||||
}
|
||||
|
||||
// CaptureInput is the whole capture request.
|
||||
type CaptureInput struct {
|
||||
Context CaptureContext
|
||||
Insights []Insight
|
||||
Tickets []Ticket
|
||||
Summary *Summary
|
||||
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"`
|
||||
}
|
||||
|
||||
// SummaryResult is the summary outcome in the receipt.
|
||||
type SummaryResult struct {
|
||||
Path string `json:"path,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"`
|
||||
Summary *SummaryResult `json:"summary,omitempty"`
|
||||
Errors []ItemError `json:"errors"`
|
||||
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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
|
||||
}
|
||||
|
||||
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||
// confirmation primitive (a direct fetch, never a semantic query).
|
||||
type StoredNote struct {
|
||||
ID string
|
||||
Path string
|
||||
ContentHash string
|
||||
Frontmatter map[string]string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Note is the brain-write payload. It carries both the wing/hall taxonomy
|
||||
// and the legacy type/domain fields so a single BrainStore serves both
|
||||
// capture insights and the existing MCP brain_write surface. Reason is
|
||||
// the supersede rationale, used only by Update.
|
||||
type Note struct {
|
||||
Content string
|
||||
Filename string
|
||||
Wing string
|
||||
Hall string
|
||||
Type string
|
||||
Domain string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// BrainStore is the brain persistence port — the shared implementation of
|
||||
// the #45 write/update/get verbs that both the MCP handlers and capture
|
||||
// call, so there is one implementation, not two. The read-after-write +
|
||||
// staleness discipline lives behind this interface so no caller carries
|
||||
// the rule.
|
||||
type BrainStore interface {
|
||||
Write(ctx context.Context, n Note) (Ref, error)
|
||||
Update(ctx context.Context, slug string, n Note) (Ref, error)
|
||||
Get(ctx context.Context, id string) (StoredNote, error)
|
||||
}
|
||||
|
||||
// IssueRef identifies a ticket touched by the tracker.
|
||||
type IssueRef struct {
|
||||
Repo string
|
||||
Number int
|
||||
URL string
|
||||
}
|
||||
|
||||
// IssueTracker is the Gitea ticket port. The implementation (#52) always
|
||||
// scopes to owner "mathias"; the port deliberately omits owner.
|
||||
type IssueTracker interface {
|
||||
CreateIssue(ctx context.Context, repo, title, body string) (IssueRef, error)
|
||||
CloseIssue(ctx context.Context, repo string, number int) (IssueRef, error)
|
||||
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||
}
|
||||
|
||||
// SummaryWriter is the ai-sessions summary port.
|
||||
type SummaryWriter interface {
|
||||
WriteFile(ctx context.Context, repo, path, content string) 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
|
||||
}
|
||||
|
||||
// AuditSink records the audit entry. The classification-aware
|
||||
// degradation/refusal policy (confidential fails closed, internal
|
||||
// degrades) is the caller's concern in #54; this port just records.
|
||||
type AuditSink interface {
|
||||
Record(ctx context.Context, e AuditEntry) error
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"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
|
||||
summaries SummaryWriter
|
||||
policy ClassificationPolicy
|
||||
audit AuditSink
|
||||
|
||||
// now is the clock, injectable for deterministic summary paths and
|
||||
// audit timestamps in tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService constructs a Service from its ports. summaries may be nil
|
||||
// when no summary persistence is wired; a CaptureInput with a Summary
|
||||
// then fails that item rather than panicking.
|
||||
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||
return &Service{brain: b, issues: tr, summaries: sw, policy: p, audit: a, now: time.Now}
|
||||
}
|
||||
|
||||
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||
|
||||
// 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 := s.resolveClassification(declared, in)
|
||||
|
||||
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})
|
||||
}
|
||||
if in.Summary != nil {
|
||||
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
var landed []string
|
||||
|
||||
for i, ins := range in.Insights {
|
||||
res, item, err := s.persistInsight(ctx, ins)
|
||||
receipt.Insights = append(receipt.Insights, res)
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
landed = append(landed, item)
|
||||
}
|
||||
|
||||
for i, tk := range in.Tickets {
|
||||
res, err := s.persistTicket(ctx, tk)
|
||||
receipt.Tickets = append(receipt.Tickets, res)
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("ticket[%d]", i), Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||
}
|
||||
|
||||
if in.Summary != nil {
|
||||
res, err := s.persistSummary(ctx, in.Context, in.Summary)
|
||||
receipt.Summary = &res
|
||||
if err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
|
||||
} else {
|
||||
landed = append(landed, "summary:"+res.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// I5: emit a request-level audit record of exactly what landed.
|
||||
// Best-effort here; the classification-aware refusal/degradation
|
||||
// policy is #54.
|
||||
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,
|
||||
}); err != nil {
|
||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
||||
}
|
||||
|
||||
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) and collects a security
|
||||
// event whenever the caller under-declared relative to a target floor.
|
||||
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string) {
|
||||
effective := declared
|
||||
var events []string
|
||||
consider := func(kind classification.TargetKind, name string) {
|
||||
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
||||
effective = classification.Stricter(effective, derived)
|
||||
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)
|
||||
}
|
||||
if in.Summary != nil {
|
||||
for _, repo := range in.Summary.ReposTouched {
|
||||
consider(classification.RepoTarget, repo)
|
||||
}
|
||||
}
|
||||
return effective, events
|
||||
}
|
||||
|
||||
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||
note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))}
|
||||
var ref Ref
|
||||
var err error
|
||||
if ins.SupersedeSlug != "" {
|
||||
note.Reason = "superseded via capture"
|
||||
ref, err = s.brain.Update(ctx, ins.SupersedeSlug, note)
|
||||
} else {
|
||||
ref, err = s.brain.Write(ctx, note)
|
||||
}
|
||||
if err != nil {
|
||||
return InsightResult{OK: false, Superseded: ins.SupersedeSlug != ""}, "", err
|
||||
}
|
||||
return InsightResult{
|
||||
ID: ref.ID, Path: ref.Path, ContentHash: ref.ContentHash,
|
||||
Superseded: ref.Superseded, OK: true,
|
||||
}, "insight:" + ref.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, error) {
|
||||
res := TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number}
|
||||
var ref IssueRef
|
||||
var err error
|
||||
switch tk.Action {
|
||||
case "create":
|
||||
ref, err = s.issues.CreateIssue(ctx, tk.Repo, tk.Title, tk.Body)
|
||||
case "close":
|
||||
ref, err = s.issues.CloseIssue(ctx, tk.Repo, tk.Number)
|
||||
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 (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
|
||||
if s.summaries == nil {
|
||||
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
|
||||
}
|
||||
path := s.summaryPath(c, sum)
|
||||
content := s.renderSummary(c, sum)
|
||||
repo := "ai-sessions"
|
||||
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
|
||||
return SummaryResult{Path: path, OK: false}, err
|
||||
}
|
||||
return SummaryResult{Path: path, OK: true}, nil
|
||||
}
|
||||
|
||||
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
|
||||
// The ref8 disambiguator is derived from the session_ref (or the title
|
||||
// when no ref is present) so distinct sessions never collide.
|
||||
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
|
||||
t := s.now().UTC()
|
||||
slug := brain.Sanitise(sum.Title)
|
||||
if slug == "" {
|
||||
slug = "summary"
|
||||
}
|
||||
seed := c.SessionRef
|
||||
if seed == "" {
|
||||
seed = sum.Title + sum.Body
|
||||
}
|
||||
sum8 := shortHash(seed)
|
||||
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
|
||||
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
|
||||
}
|
||||
|
||||
// renderSummary stamps fidelity + session metadata into frontmatter so the
|
||||
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
|
||||
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
fmt.Fprintf(&b, "title: %s\n", sum.Title)
|
||||
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
|
||||
if c.SessionRef != "" {
|
||||
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
|
||||
}
|
||||
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
|
||||
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
|
||||
if len(sum.ReposTouched) > 0 {
|
||||
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
|
||||
}
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(sum.Body)
|
||||
if !strings.HasSuffix(sum.Body, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func shortHash(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])[:8]
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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
|
||||
}
|
||||
|
||||
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
||||
if f.err != nil {
|
||||
return IssueRef{}, f.err
|
||||
}
|
||||
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) (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
|
||||
}
|
||||
|
||||
type fakeSummary struct {
|
||||
paths []string
|
||||
content []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.paths = append(f.paths, path)
|
||||
f.content = append(f.content, content)
|
||||
return 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
|
||||
}
|
||||
|
||||
func (f *fakeAudit) Record(_ context.Context, e AuditEntry) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.entries = append(f.entries, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
||||
s := NewService(b, tr, sw, 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, nil, 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{}, nil, 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, nil, 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{}, nil, 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, nil, 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, nil, 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{}, nil, 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{}, nil, 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")
|
||||
}
|
||||
|
||||
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
||||
sw := &fakeSummary{}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
||||
|
||||
ctx := baseCtx()
|
||||
ctx.Fidelity = "transcript-parse"
|
||||
ctx.SessionRef = "abc123def456"
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: ctx,
|
||||
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec.Summary)
|
||||
assert.True(t, rec.Summary.OK)
|
||||
require.Len(t, sw.paths, 1)
|
||||
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
||||
assert.Contains(t, sw.paths[0], "session-wrap")
|
||||
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/pipeline"
|
||||
@@ -219,7 +219,11 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
// 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.
|
||||
ref, err := s.store.Write(ctx, capture.Note{
|
||||
Content: a.Content,
|
||||
Filename: a.Filename,
|
||||
Type: a.Type,
|
||||
@@ -230,22 +234,7 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
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)
|
||||
}
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
// Read-after-write handle: id == relPath, content_hash == sha256 of
|
||||
// the bytes just written. path is kept for backward compatibility.
|
||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||
return json.Marshal(map[string]string{"id": relPath, "path": relPath, "content_hash": hash})
|
||||
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
||||
}
|
||||
|
||||
type brainUpdateArgs struct {
|
||||
@@ -274,50 +263,26 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
return nil, fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
opts := api.UpdateNoteOptions{Content: a.Content, Reason: a.Reason}
|
||||
switch {
|
||||
case a.Path != "":
|
||||
opts.Path = a.Path
|
||||
case strings.Contains(a.Slug, "/"):
|
||||
// slug carries a full path (issue #45: "slug ... OR full path").
|
||||
opts.Path = a.Slug
|
||||
default:
|
||||
opts.Wing, opts.Hall, opts.Slug = a.Wing, a.Hall, a.Slug
|
||||
// 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
|
||||
}
|
||||
|
||||
relPath, hash, _, err := api.UpdateNote(s.brainDir, opts)
|
||||
ref, err := s.store.Update(ctx, slug, capture.Note{
|
||||
Content: a.Content,
|
||||
Wing: a.Wing,
|
||||
Hall: a.Hall,
|
||||
Reason: a.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Best-effort wiki upkeep, mirroring brain_write: rebuild the wing
|
||||
// _index and re-tunnel cross-wing matches against the new body. Both
|
||||
// are idempotent and never block — the note is already superseded.
|
||||
if wing := wingFromRelPath(relPath); wing != "" {
|
||||
if err := brain.BuildWingIndex(s.brainDir, wing); err != nil {
|
||||
slog.Warn("brain_update: auto-index failed", "wing", wing, "err", err)
|
||||
}
|
||||
if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil {
|
||||
slog.Warn("brain_update: auto-tunnel failed", "src", relPath, "err", err)
|
||||
}
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_update", relPath)
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"id": relPath, "path": relPath, "content_hash": hash, "superseded": true,
|
||||
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||
})
|
||||
}
|
||||
|
||||
// wingFromRelPath extracts the wing segment from a structured wiki path
|
||||
// (wiki/<wing>/<hall>/<slug>.md). Returns "" for legacy/non-wiki paths.
|
||||
func wingFromRelPath(relPath string) string {
|
||||
parts := strings.Split(relPath, "/")
|
||||
if len(parts) >= 4 && parts[0] == "wiki" {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type brainGetArgs struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
@@ -327,7 +292,7 @@ type brainGetArgs struct {
|
||||
// path — the de-facto handle). Read-only; the create-path read-after-
|
||||
// write primitive that lets callers confirm a write landed without a
|
||||
// lexical re-query.
|
||||
func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
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)
|
||||
@@ -339,13 +304,13 @@ func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMess
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("id or path is required")
|
||||
}
|
||||
fm, body, hash, err := api.ReadNote(s.brainDir, target)
|
||||
note, err := s.store.Get(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"id": target, "path": target, "content_hash": hash,
|
||||
"frontmatter": fm, "body": body,
|
||||
"id": note.ID, "path": note.Path, "content_hash": note.ContentHash,
|
||||
"frontmatter": note.Frontmatter, "body": note.Body,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
@@ -46,6 +47,7 @@ type Server struct {
|
||||
vector search.VectorSearcher // nil = BM25-only retrieval
|
||||
embedder search.Embedder // nil = BM25-only retrieval
|
||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
||||
}
|
||||
|
||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||
@@ -56,7 +58,13 @@ func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.Compl
|
||||
if pipelineCfg != nil {
|
||||
cfg = *pipelineCfg
|
||||
}
|
||||
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM}
|
||||
return &Server{
|
||||
brainDir: brainDir,
|
||||
pipeline: cfg,
|
||||
llm: llm,
|
||||
answerLLM: answerLLM,
|
||||
store: brainstore.New(brainDir),
|
||||
}
|
||||
}
|
||||
|
||||
// WithReranker installs an opt-in cross-encoder reranker. When set,
|
||||
@@ -84,9 +92,11 @@ func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder)
|
||||
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
||||
if g == nil {
|
||||
s.graph = nil
|
||||
s.store.WithGraph(nil)
|
||||
return s
|
||||
}
|
||||
s.graph = g
|
||||
s.store.WithGraph(g)
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
+3
-32
@@ -40,10 +40,9 @@ if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
echo " Root context: $ROOT_CONTEXT"
|
||||
else
|
||||
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
||||
# adapters (AGENTS.md, .cursorrules, .aider.conventions.md, system-prompt.txt)
|
||||
# require the root context to regenerate correctly, so we skip them entirely
|
||||
# and only regenerate CLAUDE.md (which is project-only and inherits root via
|
||||
# tree walk in Claude Code itself).
|
||||
# adapters (AGENTS.md, system-prompt.txt) require the root context to
|
||||
# regenerate correctly, so we skip them entirely and only regenerate CLAUDE.md
|
||||
# (which is project-only and inherits root via tree walk in Claude Code itself).
|
||||
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
||||
echo "Syncing project context from $PROJECT_FILE..."
|
||||
cat "$PROJECT_FILE" > CLAUDE.md
|
||||
@@ -78,30 +77,6 @@ generate_agents() {
|
||||
echo " → AGENTS.md (root + project; Crush, Pi, Antigravity)"
|
||||
}
|
||||
|
||||
# ── Cursor ───────────────────────────────────────────────────
|
||||
generate_cursor() {
|
||||
{
|
||||
echo "# Cursor rules — auto-generated"
|
||||
echo "# Do not edit. Run: task context:sync"
|
||||
echo ""
|
||||
root_block
|
||||
cat "$PROJECT_FILE"
|
||||
} > .cursorrules
|
||||
echo " → .cursorrules (root + project)"
|
||||
}
|
||||
|
||||
# ── Aider ────────────────────────────────────────────────────
|
||||
generate_aider() {
|
||||
{ root_block; cat "$PROJECT_FILE"; } > .aider.conventions.md
|
||||
if [ ! -f .aider.conf.yml ]; then
|
||||
cat > .aider.conf.yml << 'YAML'
|
||||
read: .aider.conventions.md
|
||||
auto-commits: false
|
||||
YAML
|
||||
fi
|
||||
echo " → .aider.conventions.md (root + project)"
|
||||
}
|
||||
|
||||
# ── Generic system prompt (Open WebUI, Mods, etc.) ──────────
|
||||
generate_system_prompt() {
|
||||
{
|
||||
@@ -142,8 +117,6 @@ echo "Syncing project context from $PROJECT_FILE..."
|
||||
if [ $# -eq 0 ]; then
|
||||
generate_claude
|
||||
generate_agents
|
||||
generate_cursor
|
||||
generate_aider
|
||||
generate_system_prompt
|
||||
generate_mcp
|
||||
else
|
||||
@@ -151,8 +124,6 @@ else
|
||||
case "$adapter" in
|
||||
claude) generate_claude ;;
|
||||
agents) generate_agents ;;
|
||||
cursor) generate_cursor ;;
|
||||
aider) generate_aider ;;
|
||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||
mcp) generate_mcp ;;
|
||||
*) echo "Unknown adapter: $adapter" ;;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: close-session
|
||||
description: Disciplined end-of-session closeout for a Claude.ai chat before archiving it. Harvests the session's decisions, artifacts, and open threads and durably persists them to the brain MCP and the right Gitea repo so nothing is lost when context resets. Use this whenever the user signals they are wrapping up — phrases like "close this out", "let's wrap up", "before I archive", "session retro", "capture this before I go", "did we lose anything", or any end-of-session/handoff cue — even if they don't say the word "close". Also use when the user explicitly asks to retro, archive, or hand off a working session.
|
||||
---
|
||||
|
||||
# close-session
|
||||
|
||||
Capture a finishing Claude.ai work session into durable storage before the chat is archived and its context is lost. The goal is simple and load-bearing: **after this runs, a fresh session (or another agent) can reconstruct what was decided, what was shipped, and what is still open — without the original chat.**
|
||||
|
||||
This skill is **batch**: one session in, findings out, done. It does not loop or re-read its own fresh output semantically (see Phase 5). Run the phases in order. Stop at any confirmation gate that says STOP.
|
||||
|
||||
## Operating constraints (read first)
|
||||
|
||||
- **Gitea owner is always `mathias`.** Never guess another owner.
|
||||
- **Ground-truth at HEAD before acting.** Issue bodies and doc references rot — stale hostnames, retired services, moved endpoints. Before closing/commenting on any issue, `gitea:issue_get` it fresh. Before asserting an infra fact, verify it; do not copy it from memory or from a stale issue body.
|
||||
- **Current infra truths** (verify rather than trust, but these are the known-good baseline): Gitea is `git.d-ma.be` (not `gitea.d-ma.be`). LiteLLM is `http://koala:30401/v1/` (public `https://llm-api.d-ma.be`); piguard runs NGINX Proxy Manager only — never reference `piguard:4000` or `koala:4000`. Identity provider is Authentik (Dex migration complete).
|
||||
- **Side-effects need a confirmation gate.** Closing issues, committing files, and writing to the brain are all real writes. Surface exactly what will happen and get a clear yes before doing it. Reads are free; writes are gated.
|
||||
- **Never fabricate.** If the session didn't produce a decision worth persisting, say so and skip that write. An empty-but-honest closeout beats an invented one.
|
||||
|
||||
## Phase 1 — Harvest
|
||||
|
||||
Reconstruct what actually happened this session from the conversation itself. Produce, in working memory:
|
||||
|
||||
- **Decisions taken** — what was decided and the reasoning, not just the outcome.
|
||||
- **Artifacts produced** — issues filed/closed, PRs opened/merged, files committed, brain notes written, ADRs. Capture identifiers (issue numbers, PR numbers, paths, commit SHAs) as you go.
|
||||
- **Open threads** — what was deferred, what's blocked, what the next session should pick up.
|
||||
- **Generalizable learnings** — reusable patterns or footguns that would bite anyone again (these are brain-worthy; project status is not).
|
||||
|
||||
Be honest about fidelity: a long session compresses harder at the start than the end. Flag anything you're reconstructing rather than certain of.
|
||||
|
||||
## Phase 2 — Ground-truth Gitea state
|
||||
|
||||
For every repo touched this session, get its true current state before proposing any change. `gitea:repo_status` (owner `mathias`) gives branches + open PRs + protection in one call. For each issue you intend to close, comment on, or reference: `gitea:issue_get` it fresh and compare to what the session assumed. Note any drift (closed-already, body rotted, renamed) — you'll surface it in Phase 3.
|
||||
|
||||
Do not write anything in this phase. This is the read pass.
|
||||
|
||||
## Phase 3 — Confirm and act on issue changes
|
||||
|
||||
Present a single consolidated plan of issue actions: which to close (with closing comment), which to file (discovered-but-deferred work — token-budget gaps, recorded limitations, v2 follow-ups), which to comment on. Include the exact title/body for any new issue and the closing rationale for any close.
|
||||
|
||||
**GATE — STOP and get explicit confirmation before any issue write.** Issue closes and new issues are side-effects. Once confirmed, execute them (`gitea:issue_close`, `gitea:issue_create`, `gitea:issue_comment`, all owner `mathias`), correcting any rotted references you found in Phase 2 as you go.
|
||||
|
||||
## Phase 4 — Commit the canonical session summary
|
||||
|
||||
Write one summary file to `mathias/ai-sessions`, committed directly to `main` via `gitea:file_write_branch` (no PR — this repo is solo and unprotected; if branch protection is ever added, fall back to a branch + PR).
|
||||
|
||||
**Path:** `summaries/claudeai/<YYYY-MM>/<YYYY-MM-DD>-<topic-slug>-<chatid8>.md`
|
||||
where `<chatid8>` is the first 8 chars of the chat's UUID if known, else a short stable slug. `claudeai` has no host segment — Claude.ai is Anthropic-side, not a homelab host.
|
||||
|
||||
**Frontmatter — the REDUCED live-capture schema.** A live close-session capture cannot populate the batch-export telemetry (token counts, message counts, duration_ms, permission_mode) — those only exist in the account export pipeline. Write only what's truthfully known, and mark fidelity so a reader (or the batch pipeline) can tell a live capture from an export:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "<concise session title>"
|
||||
client: "claudeai"
|
||||
interface: "claudeai-chat"
|
||||
date: "<YYYY-MM-DD>"
|
||||
repos_touched: [<repo slugs>]
|
||||
topic_tags: [<tags>]
|
||||
outcome: "<shipped|in-progress|abandoned>"
|
||||
fidelity: "live-capture" # NOT an export; reconstructed live from chat
|
||||
captured_by: "close-session-skill"
|
||||
---
|
||||
```
|
||||
|
||||
Do not invent the export-only fields. `fidelity: live-capture` is the honest signal; if the batch export later produces a richer summary for the same session, the export is source of truth and supersedes this.
|
||||
|
||||
**Body** (keep it reconstructable, not exhaustive):
|
||||
```markdown
|
||||
## One-paragraph summary
|
||||
## Decisions
|
||||
## Key artifacts
|
||||
## Open threads
|
||||
```
|
||||
|
||||
**GATE — STOP, show the full file (path + frontmatter + body), get explicit confirmation before committing.**
|
||||
|
||||
## Phase 5 — Brain orientation note (the durable "where we are" record)
|
||||
|
||||
Write one brain note so a fresh session can orient without the chat. This uses the `brain_update`/`brain_get` verbs (live since 2026-06).
|
||||
|
||||
**Target:** `wing: <domain>` (the project/topic domain, e.g. `hyperguild`, `jepa-fx`), `hall: decisions`. The note is a knowledge-type record (a decision/orientation), grouped by knowledge-type, not by interface surface.
|
||||
|
||||
**Batch read-after-write discipline (important — do these in order, do not interleave):**
|
||||
|
||||
1. **Read first, before any write.** Check whether an orientation note already exists for this wing/topic. Do your "does this already exist / what should I supersede" reads NOW, up front. BM25/keyword search and `brain_get` are immediate; semantic/vector search may lag up to ~5 min after a write, so never rely on a semantic query to find something you wrote earlier in this same run.
|
||||
2. **Write or supersede:**
|
||||
- **New note** → `brain_write` (wing, hall: decisions). Returns `{id, path, content_hash}`.
|
||||
- **Superseding a prior orientation note** → `brain_update` (slug or path, wing, hall, content, reason). Whole-note replace; stamps `supersedes`/`updated_at`; returns `{id, path, content_hash, superseded}`. Use this instead of a second `brain_write` to the same slug — blind re-write creates duplicates/contradictions, which is the exact failure brain_update exists to prevent.
|
||||
3. **Confirm it landed** via `brain_get(id)` and check the returned `content_hash` matches what the write returned. This is the read-after-write confirmation — do it with `brain_get`, never a semantic query.
|
||||
|
||||
**RULE: no semantic/vector brain query after the first `brain_update` in this run.** The batch shape makes this natural — read up front, write, confirm by id. If you ever find the skill wanting to semantic-search a just-superseded note, stop and flag it (that's the signal the staleness window matters and needs the synchronous-reembed follow-up).
|
||||
|
||||
**GATE — STOP, show the note (target wing/hall, new-vs-supersede, full content), get explicit confirmation before the brain write.**
|
||||
|
||||
After the note lands, if it relates to a note in another wing, create the cross-link inline with `brain_tunnel(source, target)` (idempotent; both paths brain-relative, must be in different wings). Optionally append a `session_log` entry (`session_id`, `skill: close-session`, `phase`, `final_status`) for telemetry. Both are now callable directly from Claude.ai — no Claude Code/Crush handoff needed.
|
||||
|
||||
## Phase 6 — Verdict
|
||||
|
||||
Deliver a final "safe to archive" verdict in the chat. Either:
|
||||
|
||||
- **SAFE TO ARCHIVE** — list what landed (issues closed/filed with numbers, summary path, brain note id, any tunnels) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
||||
- **NOT YET** — name the specific gate that wasn't passed or the write that failed, and what to do about it.
|
||||
|
||||
Never claim safe-to-archive if any gated write was declined or errored. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
||||
|
||||
## Why the gates and the batch discipline matter
|
||||
|
||||
The whole point is durability across a context reset. Every gate is a place where a wrong write would silently corrupt the record (close the wrong issue, overwrite a good brain note, commit a half-truth). The batch read-discipline in Phase 5 exists because the brain's vector index refreshes out-of-band: write-then-semantically-reread in the same run can read stale, so the skill front-loads reads and confirms writes by id. Get those right and the skill does what it promises — nothing important is lost when the chat goes away.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Capture capability — use-case & BDD specification
|
||||
|
||||
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
|
||||
privileged cross-harness write path touching brain + Gitea + ai-sessions.
|
||||
**Tracks:** hyperguild #49.
|
||||
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in
|
||||
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
||||
`brain/wiki/homelab/decisions/no-centralized-cross-harness-observer-2026-06-17.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Use-case (Clean Architecture form)
|
||||
|
||||
**Name:** CaptureSession
|
||||
**Actor:** A harness acting on the user's behalf (claude.ai Chat/Cowork/Code/Design, Claude Code
|
||||
CLI, Crush, Pi, LLM Council, Agentsquad executor/reviewer) — or the user directly.
|
||||
**Goal:** Durably persist a finished session's valuable output — insights → brain, action items →
|
||||
Gitea tickets, optional summary → ai-sessions — with one uniform invocation, identical core
|
||||
behaviour across harnesses.
|
||||
|
||||
**Primary success scenario (essential steps):**
|
||||
1. Caller assembles capture input (insights, tickets, optional summary) + context (harness,
|
||||
session_ref, fidelity, actor, **data-classification**).
|
||||
2. System validates the whole request (fail-closed).
|
||||
3. System resolves **effective classification** (stricter of caller-declared and target-derived)
|
||||
and the **server-derived harness origin** (from the authenticated principal). It checks the
|
||||
**sovereignty gate** (I1): if effective classification is confidential AND the origin is a
|
||||
non-sovereign (us-nexus) surface, the capture is **refused** before any write.
|
||||
4. System persists insights (write or supersede), tickets (create/close/comment), summary — each
|
||||
best-effort, recording per-item outcome.
|
||||
5. System emits an **audit record** (I5) of who/what captured what, when, via which principal.
|
||||
6. System returns a structured, partial-aware receipt.
|
||||
|
||||
**Architectural shape:** the *logic* is a shared use-case (`CaptureService`), invoked **per-harness
|
||||
against the caller's own credentials** (distributed consolidation — no high-degree observer node).
|
||||
A central authenticated relay endpoint exists ONLY as a fallback for harnesses that cannot run the
|
||||
use-case in-process (Crush/Pi/headless); the relay holds no standing visibility and retains nothing
|
||||
beyond the I5 audit log.
|
||||
|
||||
---
|
||||
|
||||
## 2. Invariant obligations (acceptance gates, not nice-to-haves)
|
||||
|
||||
| Invariant | Obligation on `capture` |
|
||||
|---|---|
|
||||
| **I1 sovereign containment** | A confidential-classified session MUST NOT be captured through a us-nexus harness. Harness origin is **server-derived from the authenticated principal** (not caller-asserted). Classification uses **model (C)**: caller declares, server cross-checks the target's tag, **stricter wins**, mismatch logged. See §4.1–4.2. |
|
||||
| **I2 deliberate acceptance** | The *distributed-library* form opens no new acceptance. IF a central relay node is deployed, its cross-harness reach MUST be entered in `infra/docs/security-baseline.md` with Why-accepted / Revisit-if before it ships. |
|
||||
| **I3 GitOps reconcilability** | IF `capture` runs as a deployed service, its manifest lives under `infra/k3s/apps/**` (sovereign source, Flux-reconciled). No untracked runtime. |
|
||||
| **I4 decisions captured** | The distributed-vs-central decision and the intent-named-verb pattern are recorded (ADR + brain). |
|
||||
| **I5 auditability** | Every capture emits a request-level audit record (actor/principal, harness, items written, timestamp) to the alloy/loki substrate. **Classification-aware degradation** (§4.4): confidential + sink-down → hard-refuse; internal/public + sink-down → durable local buffer + ntfy + reconcile. Floor: refuse if nothing can record the audit. |
|
||||
|
||||
---
|
||||
|
||||
## 3. BDD scenarios (Gherkin)
|
||||
|
||||
```gherkin
|
||||
Feature: Capture session value uniformly across harnesses
|
||||
As an operator working across many AI harnesses
|
||||
I want one uniform command to persist insights and file tickets
|
||||
So that valuable session output is never lost and is always auditable
|
||||
|
||||
Background:
|
||||
Given a brain store, a Gitea issue tracker, and an ai-sessions summary writer
|
||||
And the caller is authenticated with a principal
|
||||
And the session context declares a harness, a fidelity, and a data classification
|
||||
|
||||
# --- Core happy path ---
|
||||
Scenario: Capture insights and tickets from a non-confidential session
|
||||
Given a session classified as "internal"
|
||||
And the capture input has 2 insights and 1 ticket to create
|
||||
When capture is invoked
|
||||
Then both insights are written to the brain and their ids and content hashes are returned
|
||||
And the ticket is created in the named repo under owner "mathias"
|
||||
And an audit record is emitted naming the principal, harness, and items written
|
||||
And the receipt reports every item as ok
|
||||
|
||||
# --- I1: sovereignty gate (the load-bearing refusal) ---
|
||||
# Harness origin is server-derived from the authenticated principal, never from context.harness.
|
||||
Scenario: Refuse capture of a confidential session through a us-nexus harness
|
||||
Given a session whose effective classification is "confidential"
|
||||
And the authenticated principal resolves to a us-nexus harness origin
|
||||
When capture is invoked
|
||||
Then the capture is refused before any write
|
||||
And no insight, ticket, or summary is persisted
|
||||
And the refusal names the sovereignty invariant as the reason
|
||||
|
||||
Scenario: Allow capture of a confidential session through a sovereign harness
|
||||
Given a session whose effective classification is "confidential"
|
||||
And the authenticated principal resolves to a sovereign-soil harness origin
|
||||
When capture is invoked
|
||||
Then the capture proceeds and persists normally
|
||||
|
||||
Scenario: Ignore a caller-asserted harness label and use the server-derived origin
|
||||
Given the request context asserts harness "sovereign-soil"
|
||||
But the authenticated principal resolves to a us-nexus origin
|
||||
And the session classification is "confidential"
|
||||
When capture is invoked
|
||||
Then the capture is refused
|
||||
And the server-derived origin is used, not the asserted label
|
||||
And the asserted-vs-derived discrepancy is logged as a security event
|
||||
|
||||
# --- I1: classification model (C) — stricter of declared vs target-derived wins ---
|
||||
Scenario: Take the stricter classification when caller and target disagree
|
||||
Given the caller declares classification "internal"
|
||||
But the target wing/repo is tagged "confidential"
|
||||
When capture is invoked
|
||||
Then the effective classification is "confidential"
|
||||
And the declared-vs-derived mismatch is logged as a security event
|
||||
And the I1 gate is evaluated against "confidential"
|
||||
|
||||
Scenario: Honour a caller raising sensitivity above the target's tag
|
||||
Given the caller declares classification "confidential"
|
||||
And the target wing/repo is tagged "internal"
|
||||
When capture is invoked
|
||||
Then the effective classification is "confidential"
|
||||
And the capture is gated as confidential
|
||||
|
||||
# --- Supersession + staleness discipline (reuses #45 / #47 resolution) ---
|
||||
Scenario: Supersede a prior insight rather than duplicating it
|
||||
Given an insight whose context names an existing note to supersede
|
||||
When capture is invoked
|
||||
Then the existing note is updated in place, not duplicated
|
||||
And the prior content hash is recorded in the superseding note
|
||||
And read-after-write confirmation uses a direct fetch, never a semantic query
|
||||
|
||||
# --- Validation: fail-closed ---
|
||||
Scenario: Reject a malformed request before any write
|
||||
Given a capture input with an invalid wing/hall or unknown repo
|
||||
When capture is invoked
|
||||
Then the request is rejected with a validation error
|
||||
And nothing is written to the brain, Gitea, or ai-sessions
|
||||
|
||||
# --- Partial failure: best-effort + honest receipt ---
|
||||
Scenario: Report partial success when one item fails mid-capture
|
||||
Given a capture input with 2 insights and 1 ticket
|
||||
And the second insight write will fail
|
||||
When capture is invoked
|
||||
Then the first insight and the ticket are persisted
|
||||
And the second insight is reported as failed in the receipt
|
||||
And no rollback is attempted
|
||||
And the audit record reflects exactly what landed
|
||||
|
||||
# --- Dry run ---
|
||||
Scenario: Preview a capture without writing
|
||||
Given a valid capture input with dry_run true
|
||||
When capture is invoked
|
||||
Then the would-be receipt is returned
|
||||
And nothing is written anywhere
|
||||
|
||||
# --- I5: auditability is classification-aware (confidential fails closed) ---
|
||||
Scenario: Confidential capture hard-refuses when the central audit sink is down
|
||||
Given the effective classification is "confidential"
|
||||
And the central audit substrate (loki) cannot be written to
|
||||
When capture is invoked
|
||||
Then the capture is refused before any write
|
||||
And the reason names the auditability invariant
|
||||
# Confidential work must be centrally auditable at write time — no buffered exception.
|
||||
|
||||
Scenario: Internal capture degrades to a durable local buffer when the sink is down
|
||||
Given the effective classification is "internal" or "public"
|
||||
And the central audit substrate (loki) cannot be written to
|
||||
When capture is invoked
|
||||
Then the capture proceeds
|
||||
And the audit record is written to a durable LOCAL fallback buffer
|
||||
And an ntfy alert is emitted naming the degraded audit state
|
||||
And the receipt flags that audit was buffered locally, not centrally recorded
|
||||
|
||||
Scenario: Locally buffered audit records reconcile to the central sink on recovery
|
||||
Given internal-tier audit records were buffered locally during a sink outage
|
||||
When the central audit substrate becomes reachable again
|
||||
Then the buffered records are replayed to the central sink
|
||||
And the local buffer is cleared only after confirmed central write
|
||||
|
||||
Scenario: Even internal capture refuses if neither sink nor local buffer can be written
|
||||
Given the effective classification is "internal" or "public"
|
||||
And neither the central sink nor the local fallback buffer can be written
|
||||
When capture is invoked
|
||||
Then the capture is refused
|
||||
And the reason names the auditability invariant
|
||||
# Degrade-and-warn has a floor: if NOTHING can record the audit, do not write.
|
||||
|
||||
# --- Summary fidelity (collision rule from the retro work) ---
|
||||
Scenario: A richer-fidelity summary supersedes a thinner one for the same session
|
||||
Given a summary already exists for session_ref X at fidelity "live-capture"
|
||||
And a new summary arrives for session_ref X at fidelity "transcript-parse"
|
||||
When capture is invoked
|
||||
Then the transcript-parse summary supersedes the live-capture one
|
||||
And the live-capture summary is not left as a contradicting duplicate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Resolved decisions (2026-06-22)
|
||||
|
||||
These were open questions at draft; resolved in the 2026-06-22 review session. Recorded here as
|
||||
binding design decisions for the build.
|
||||
|
||||
1. **Classification trust — model (C): caller-declares + server-cross-checks, stricter wins.**
|
||||
The caller declares `context.classification`; the server **independently derives** the target's
|
||||
classification (from the target wing/repo's classification tag) and gates on the **stricter of
|
||||
the two**. The caller can voluntarily *raise* sensitivity but can never *lower* it below the
|
||||
target's floor. A declared-vs-derived **mismatch is logged as a security event** (I5).
|
||||
- **Prerequisite (new build work):** a classification taxonomy (e.g. `public` /
|
||||
`internal` / `confidential`) and a per-wing / per-repo classification tag the server can read.
|
||||
This must exist before the I1 gate is load-bearing. Tracked as a sub-task of #49.
|
||||
- **Implemented (#50):** taxonomy `public < internal < confidential` (ordered so "stricter wins"
|
||||
is `max`) in `ingestion/internal/classification/`. Tags are read from an optional
|
||||
`classification.yaml` at the brain root (`wings:` / `repos:` maps); absent entries fall to
|
||||
built-in defaults (`client-*` → confidential; `hyperguild`/`homelab` → internal; everything
|
||||
else → **confidential, fail-safe**). `Config.Derive(Target)` is the function the use-case
|
||||
calls. See brain `wiki/hyperguild/decisions/capture-classification-taxonomy`.
|
||||
- Rationale: composes with decision 2; fails safe; honours a caller flagging something *more*
|
||||
sensitive than its destination. Pure caller-trust (A) was rejected — it makes the gate theatre.
|
||||
|
||||
2. **Sovereign-harness determination — server-derived, not caller-asserted.**
|
||||
"Is this harness us-nexus / sovereign?" is derived from the **authenticated principal/origin**
|
||||
(the OAuth2 identity), never from `context.harness`. `context.harness` survives only as a
|
||||
self-reported label for the audit log — descriptive telemetry, **never a gate input**. A control
|
||||
keyed on an attacker-suppliable value is not a control.
|
||||
|
||||
3. **Central relay — ships in v1, with the I2 ledger entry.**
|
||||
The relay is required, not optional: claude.ai (Chat/Cowork/Design), Crush, Pi, and LLM Council
|
||||
cannot run the use-case library in-process, and those are primary day-to-day surfaces. Deferring
|
||||
the relay would ship a capability that doesn't work from the interfaces actually in use. Because
|
||||
the relay is a (thin, no-standing-visibility, audit-only-retention) central node, its cross-harness
|
||||
reach **must be entered in `infra/docs/security-baseline.md`** with Why-accepted / Revisit-if
|
||||
**before it ships** (I2). That ledger entry is v1 work, not a follow-up.
|
||||
|
||||
4. **Audit-sink-down — classification-aware: confidential fails closed, internal/public degrades.**
|
||||
The posture inherits from the effective classification (decision 1), so there is one coherent
|
||||
sensitivity model rather than a separate availability policy:
|
||||
- **Confidential + central audit sink unreachable → hard-refuse.** No buffer, no proceed.
|
||||
Confidential work must be centrally auditable *at write time*; "buffer and reconcile later"
|
||||
introduces a buffer-integrity question (can a write tamper with its own pending audit record?)
|
||||
that must not exist for confidential data. The simplicity of "refuse" is itself the assurance
|
||||
asset — trivially true, nothing to poke holes in.
|
||||
- **Internal / public + central sink unreachable → degrade-and-warn** with a durable local buffer
|
||||
+ ntfy alert + reconcile-on-recovery (the earlier Q4 design, now scoped to lower tiers). Keeps
|
||||
capture available for your own homelab work during an observability outage; negligible risk
|
||||
since the buffered record is still durable and the data isn't client-confidential.
|
||||
- **Floor (all tiers):** if *nothing* — neither central sink nor (for internal/public) the local
|
||||
buffer — can record the audit, capture **refuses**. No tier writes wholly un-audited.
|
||||
- Rationale: matches assurance cost to data sensitivity, exactly as the I1/sovereignty model
|
||||
does for placement. Presentable to a due-diligence client as "audit posture is
|
||||
classification-aware: confidential fails closed, internal degrades gracefully" — which
|
||||
demonstrates the judgment, not just a binary. Couples Q4 to Q1's classification machinery
|
||||
(being built anyway) and removes the buffer-integrity rabbit hole for the only tier where it
|
||||
mattered.
|
||||
Reference in New Issue
Block a user