228 Commits

Author SHA1 Message Date
Mathias
f8cf27e5de merge: claudewatcher (closes hyperguild#27, refs infra#73)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
2026-05-25 19:59:13 +02:00
Mathias
49b188e9c9 feat(server): wire claudewatcher behind CLAUDE_SESSIONS_DIR
Opt-in by setting CLAUDE_SESSIONS_DIR to the ~/.claude/projects path.
When set, the server starts claudewatcher.Watch in a goroutine that
ticks every CLAUDE_INGEST_INTERVAL seconds (default 60). Requires
BRAIN_PG_DSN for the cursor table — fail-fast if missing.

Each Batch becomes one wiki note at:
  brain/wiki/claude-sessions/facts/session-<host>-<session_id>.md

with frontmatter type=source + domain=<project basename>. Per-turn
content capped at 2000 chars (full transcripts stay in
~/.claude/projects already); the brain entry is a digest, not a
mirror.

CLAUDE_INGEST_HOST overrides the os.Hostname()-derived host label,
useful when multiple ingestion pods consume the same DSN from
different machines.

Closes hyperguild#27.

Bump-Type: minor
2026-05-25 19:59:07 +02:00
Mathias
bc011cc1f0 feat(claudewatcher): ingest Claude Code session transcripts into brain
New package internal/claudewatcher. The volume gate (24 turns/week of
agentsquad logs vs 500/week gate) exposed that the real signal lives
in daily Claude Code usage at ~/.claude/projects/*/<uuid>.jsonl, not
in agentsquad output. This package captures that signal. See infra#73
Track E + hyperguild#27 for the full reframe.

Components:
- parser: tolerant JSONL parser over the observed Claude Code session
  schema (user / assistant / attachment / system + bookkeeping types).
  Skip-flag fast-paths queue-operation, last-prompt, permission-mode,
  ai-title, bridge-session, file-history-snapshot.
- scrubber: 11-rule fail-closed regex set for credential shapes
  (bearer, postgres URIs, PEM, ssh-key, ghp_/sk-/sk-ant-/AKIA, homelab
  env tokens, SOPS markers). Drop turn + log on match.
- cursor: postgres-backed claude_session_cursors table, keyed by
  (host, file_path) with byte_offset. Resumable across pod restarts.
- watcher: poll loop. Walks SessionsDir, processes each .jsonl from
  its cursor offset, runs scrubber, emits a Batch per file to a
  Sink interface, advances cursor on successful Ingest.

No classifier integration in this commit — every kept turn is emitted
in a per-session batch. The cmd/server wiring (next commit) routes
batches to brain/wiki/claude-sessions/facts/. Classifier-driven hall
routing (decisions / failures / hypotheses) is a follow-up.

19 unit tests across parser + scrubber + watcher. task check green.

Refs: infra#73, hyperguild#27
2026-05-25 19:58:58 +02:00
Mathias
2726896079 feat(mcp): wire brain_context tool
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
Returns top-N relevant brain entries for a project context. Combines
BM25 hits on project name with 2-hop graph expansion via Track A's
graphstore (when BRAIN_GRAPH_ENABLED). Closes hyperguild#28.

Notes on implementation choices that deviate slightly from the spec:
- Excerpt length: 200 chars per spec (vs the 300 used by search.Result).
  truncateExcerpt clamps the already-stripped BM25 excerpt; graph-only
  neighbours load their excerpt from disk via a private readExcerpt
  helper (search.hydrate is unexported).
- Graph scoring: 0.6 / max(1, distance) per neighbour, so distance-1
  contributes 0.6 and distance-2 contributes 0.3. BM25 hits decay
  linearly from 3.0 (rank-0) to 1.0 (rank-2), giving BM25 hits a
  natural ceiling above pure-graph hits while still letting a doc
  surfaced via both edge types outrank a BM25-only one.
- Test placement: package mcp (internal) rather than mcp_test, because
  graphReader is unexported and WithGraph only accepts *PGStore; an
  internal test can install a dual-interface fake directly on s.graph
  without spinning up postgres.

Bump-Type: minor

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:53:14 +02:00
Mathias
2b7bbe38c7 docs(eval): record M4 + M4b scorer runs — phase 2 gate cleared (infra#72)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
Tier-weighted retrieval against the qa-2026-05.md 20-question set:

| run                            | top-1 | top-3 |
|--------------------------------|-------|-------|
| baseline (pre-phase-1)         | 20%   | 65%   |
| post phase 1 (parser+content)  | 20%   | 70%   |
| post M4 (tier weighting)       | 30%   | 75%   |
| post M4b (entities → K tier)   | 35%   | 80%   |

Net Phase 2 lift: +15pt top-1, +15pt top-3 — comfortably above the
≥10pt close-gate set in infra#72.

Three remaining misses are content-keyword issues, not structure
issues (the questions don't share enough lexical surface with the
target entries to surface via BM25 alone). Vector search would
help here but the iguana embedder is off-mesh (see infra#64).
2026-05-25 18:51:29 +02:00
Mathias
1b00cbc0ae fix(search,graph): M4b wiki/entities/ → tier=knowledge
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 3s
Initial M4 mapping put wiki/entities/* in tier=note. Post-M4 eval
regressed qwen35-9b-fast from rank 2 → off top-5: knowledge entries
that cite the entity in passing now outscore the entity page itself
(1.5× weight vs 1.0×).

Entity anchor pages are durable facts about concrete things — they
map cleanly to the knowledge/facts/ slot in the post-M3 layout
target. Promote them now so the path inference matches.

Eval re-run after deploy is in infra#72.
2026-05-25 18:49:37 +02:00
Mathias
4f78fecd06 feat(search): M4 tier-weighted BM25 re-rank (infra#72)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 3s
The eval set under brain/eval/qa-2026-05.md showed BM25 top-1 at 20%
with 5 of the missing slugs being short focused knowledge entries
that lost to long aggregate docs on raw term-frequency. Tier weighting
addresses that without touching the BM25 algorithm itself.

How

- Result struct gains a Tier field, populated during the file walk
  via extractTier (frontmatter wins, path prefix as fallback —
  mirrors the graph.inferTierFromPath logic so the two callers stay
  in lockstep).
- After the existing sort (and optional hybridMerge), do a final
  stable re-sort by float64(Score) * tierWeight(Tier). Knowledge
  ×1.5, note ×1.0, inbox ×0.3, unknown ×1.0.
- hydrate() (vector-only hits) also fills Tier so re-ranking covers
  the hybrid path.

Test covers the load-bearing case: a long note-tier doc with raw=10
loses to a short knowledge-tier doc with raw=8 after weighting
(8×1.5=12 vs 10×1.0=10).

Measurement gate is in infra#72: re-run brain/eval/score.py against
the live brain after this image lands; close the issue when top-1
hit rate lifts by ≥10 absolute points.
2026-05-25 18:45:20 +02:00
Mathias
d5f112b600 feat(graph,graphstore): M2 parse tier+topic from frontmatter, persist via Upsert (infra#72)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 4s
extract.go now reads `tier:` and `topic:` from YAML frontmatter, with
a path-based fallback when frontmatter is absent (the pre-M3 state on
every existing entry):

  knowledge/* → tier=knowledge
  notes/*     → tier=note
  wiki/**     → tier=note   (sources + concepts + entities are I-level)
  inbox/**, raw/**, sessions/**, clips/** → tier=inbox

Frontmatter wins when present — covers the M3-migrated case where an
entry's path may not match the tier the author chose for it.

UpsertEntity persists both columns. M1's schema already has them.

Backfill on next pod start populates tier for the whole corpus
without any file moves; M3 will follow up with the actual layout
migration and explicit frontmatter writes.
2026-05-25 12:35:38 +02:00
Mathias
ea9518e712 feat(graphstore): M1 add tier + topic columns to brain_entities (infra#72)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 15s
CI / Mirror to GitHub (push) Successful in 3s
Schema-only change. DDL adds tier + topic on fresh tables and uses
ADD COLUMN IF NOT EXISTS on existing tables (idempotent across pod
restarts). New conditional indexes match the wing/hall pattern.

No behavior change in this commit — UpsertEntity still writes only
the original columns; tier + topic stay '' on every row. M2 plumbs
the parser through. The empty default means existing queries are
untouched until the rest of the chain lands.

Part of infra#72 — brain DIKW tier redesign.
2026-05-25 07:17:39 +02:00
Mathias
e34cd6c12b docs(eval): record post-fix scorer run — phase 1 lift insufficient
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
Top-1 stayed at 20% (4/20), top-3 +5pt (65→70%) after:
- extract.go wing/topic parser fix (commit 3084c41)
- qwen35-9b-fast entity pad (was 239-byte stub → full entity)
- grafana entry: add "pod restart" synonym to lesson body
- dangling refs stripped from index.md + entities/k3s.md

The only retrieval move: qwen35-9b-fast climbed from rank 0 (off top-5)
to rank 2 — the entity pad worked. Other 5 misses are ranker behaviour
on already-keyword-overlapping entries; BM25 doesn't weight the right
slugs to the top.

Per the proposal's gate (≥10pt lift = stop, <10pt = Phase 2 justified),
the DIKW tier redesign earns its cost. Next session: tier column +
file moves + tier-weighted retrieval, then re-measure against this
same eval set.
2026-05-24 22:48:48 +02:00
Mathias
3084c4173d fix(graph): route wiki/<flat>.md to Type=knowledge, not Type=hall with filename-as-wing
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
classifyByPath had a hole: paths like wiki/index.md or wiki/<slug>.md
(direct children of wiki/, no subdirectory) hit the default branch and
wrote Wing=parts[1] — which IS the filename, not a wing. Symptom in
brain_entities: rows like (slug=index, wing=index.md) and
(slug=autobe-..., wing=autobe-evaluation-pattern-....md).

Fix: when len(parts) < 3 (no subdirectory at all), fall through to
Type=knowledge and let frontmatter set wing/hall if present.

Add brain/eval/ artifacts at the same time:
- qa-2026-05.md — 20 hand-authored Q→expected-slug pairs covering the
  homelab knowledge corpus across mcp, dex, gitops, postgres, go,
  models, methodology
- score.py — calls brain_query for each pair, scores top-1 + top-3,
  emits per-question detail. BRAIN_MCP_TOKEN via env.

Pre-fix baseline against the live brain: top-1 = 20% (4/20),
top-3 = 65% (13/20). Six hard misses where the expected slug doesn't
even land in the top-5.

Used to gate the phase 2 DIKW redesign (infra#62 follow-up): if
phase 1 fixes (this parser fix + 20 backlink authoring on top
orphans) lift top-1 by <10 absolute points, structure is the
bottleneck and the tier redesign is justified.
2026-05-24 22:33:04 +02:00
Mathias
72be87b4e7 chore(routing): flip LITELLM_BASE_URL default to https://llm-api.d-ma.be
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 3s
Follow-up to infra#70. LiteLLM moved off piguard into k3s and the
public llm-api.d-ma.be hostname now upstreams to koala:30401. The
piguard:4000 default in the source bit-rots — works today because
piguard:4000 is still alive during the 7-day soak, breaks the moment
the compose comes down.

Pointing the default at the public hostname survives the cutover
without needing a follow-up. Production deploys via k3s already
override via env (in-cluster Service DNS) so this only affects local
dev shells without LITELLM_BASE_URL set.

- internal/config/routing.go: comment + envOr fallback
- internal/config/routing_test.go: expected value in defaults test
- scripts/smoke-routing.sh: shell default

task check: clean (tests + vet + govulncheck).
2026-05-24 15:06:23 +02:00
Mathias
153ef6ccac feat(graph): GraphRAG augment brain_answer with top-hit subgraph
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 3s
Commit 4 of Track A — the no-shelfware close-out the grill demanded.
brain_answer now folds the 1-hop outgoing neighbourhood of its top
BM25/rerank hit into the LLM's context as a <related> block when
BRAIN_GRAPH_ENABLED is on. With the flag off the prompt is byte-for-
byte identical to the pre-Track-A behaviour, so existing tests still
pass without modification.

The hop list contains slug, edge_type, doc_path — no extra retrieval
pass, no second LLM call, no file reads. The model can ignore the
block when irrelevant; when it adds signal we get GraphRAG for free.

Refs: docs/superpowers/specs/2026-05-homelab-training-graph-next-step.md
in infra repo + grill addendum item "Track A: GraphRAG wiring into
brain_answer is mandatory in same commit chain (no shelfware risk)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:24:45 +02:00
Mathias
2148565ee6 feat(mcp): expose brain_graph tool — neighbors, subgraph, path
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
Commit 3 of Track A. The MCP server now publishes a new tool that
opens the brain knowledge graph (entities + wikilink edges) for
external consumers (claude.ai connectors, gitea-mcp, agentsquad).

- tools_graph.go: brain_graph handler dispatches by op:
    neighbors  — 1-hop outgoing from slug, optional edge_type filter
    subgraph   — every reachable slug within depth hops (≤6)
    path       — shortest directed path src→dst within depth (≤8)
  Returns slug + entity metadata + edge_type + hop distance.

- server.go: handleCall routes "brain_graph" to brainGraph.

- handlers.go: tool descriptor with the op enum + per-op required
  fields documented in the description.

- server_test.go: TestServerToolsList expects brain_graph in the
  listing.

The tool returns an error when BRAIN_GRAPH_ENABLED is unset — same
shape as brain_answer when the answer LLM is unconfigured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:23:18 +02:00
Mathias
f43e0bccbf feat(graph): wire graphsync into MCP write/ingest/tunnel handlers
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 4s
Commit 2 of Track A. Service stays a no-op until BRAIN_GRAPH_ENABLED=
true; flipping it on creates the schema (idempotent), starts indexing
every successful write, and optionally backfills the existing brain
dir.

- internal/graphsync: best-effort wrapper around graph.Extract +
  graphstore. IndexDoc reads docPath under brainDir, parses, upserts
  entity + replaces edges. BackfillFromBrainDir walks wiki/ +
  knowledge/. Both are no-ops on nil store so callers wire
  unconditionally.

- mcp.Server gains WithGraph builder + graphsync.Store field.
  brain_write, brain_ingest, brain_ingest_raw, brain_tunnel call
  indexInGraph after success — failures slog.Warn but never
  propagate (graph is augmentation, not correctness).

- cmd/server gates the wiring on BRAIN_GRAPH_ENABLED=true (default
  off so first rollout doesn't surprise). BRAIN_GRAPH_BACKFILL=true
  triggers a one-shot walk of the brain dir on boot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:21:33 +02:00
Mathias
f53ee18cb6 feat(graph): add brain_entities + brain_edges store and wikilink parser
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 3s
Foundation for Track A (GraphRAG on top of existing wiki). Two new
packages, both unwired — service behaviour unchanged until commit 2
hooks the pipeline.

- internal/graph: pure parser. Extract() walks markdown + frontmatter
  and emits one Entity + N wikilink Edges per doc. Dedupes per (dst,
  line), ignores self-references, classifies hall/concept/entity/
  source/knowledge from path layout.

- internal/graphstore: pgx-backed PGStore mirroring vectorstore's
  shape. Idempotent Init() creates brain_entities + brain_edges with
  indexes on src_slug, dst_slug, src_doc, wing, type. Operations:
  UpsertEntity, ReplaceEdgesForDoc (tx), DeleteByDoc, Neighbors,
  Subgraph (recursive CTE, depth ≤6), Path (shortest path, depth ≤8).

Schema lives on the shared postgres18 instance alongside the
brain_embeddings table — no new datastore. See
docs/superpowers/specs/2026-05-homelab-training-graph-next-step.md
in infra repo + infra#62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:18:08 +02:00
Mathias
c153e9105c ci: retrigger build after chassis repo made public
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 3s
Same reason as gitea-mcp ci retrigger commit — mcp-chassis was created
private; the ingestion port (commit ca22df2) couldn't fetch it in CI.
Chassis is now public; this empty commit retriggers the Build and deploy
pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:17:55 +02:00
Mathias
ce96a6a571 fix(ci): allow ingestion Dockerfile to fetch internal gitea modules
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
Same fix as gitea-mcp commit for the same reason — mcp-chassis (added
in commit ca22df2) is hosted at gitea.d-ma.be and Gitea returns http://
in its go-import meta tag, breaking the default go module resolution
inside the Docker build.

GOPRIVATE+GOPROXY=direct+GOSUMDB=off plus a git config insteadOf rewrite
to flip http:// → https:// for gitea.d-ma.be clones.

Without this, hyperguild CI Build and deploy failed on the chassis
port (sha=ca22df2). Reapplying CI should now succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:12:09 +02:00
Mathias
ca22df2d6a feat(ingestion): migrate to gitea.d-ma.be/mathias/mcp-chassis v0.1.0
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s
Second port of the MCP chassis (gitea-mcp was first, commit 658f4ba).
Closes the chassis-adoption loop on the two highest-LOC consumers.

Changes:
- Drop ingestion/internal/auth/ entirely (jwt.go + jwt_test.go +
  protected_resource.go + protected_resource_test.go) — chassis provides
  JWTValidator + ProtectedResourceHandler with identical semantics.
- Drop ingestion/internal/mcp/auth.go (BearerAuth function, ~65 LOC)
  and the integration test auth_test.go (~200 LOC) — chassis
  BearerMiddleware replaces it. Static-Bearer-or-Dex-JWT precedence and
  RFC 9728 resource_metadata challenge behavior preserved 1:1.
- cmd/server/main.go: import chassis as `chassisauth`, rewire the three
  call sites. Use realm="brain" in the BearerMiddleware call so a 401
  challenge identifies the resource as the brain MCP.

OAuth client_credentials handler (ingestion/internal/oauth) stays —
chassis v0.1.0 covers only the JWT path; OAuth flow is a candidate for
chassis v0.2.0 once a second MCP needs it (rule of three).

Net delta: -~330 LOC of duplicated auth code; +1 import; +1 GOPRIVATE
env requirement on dev machines (documented in the spike handoff
2026-05-22-mcp-chassis-spike.md).

task check green (lint + test + vet + govulncheck).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:43:11 +02:00
Mathias
e49b36e463 feat(ingestion): expose Prometheus /metrics for brain query latency
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s
Closes infra#50.

Adds an internal/metrics package with a hand-rolled Prometheus
exposition layer (stdlib + sync/atomic only — no new dep) and wraps the
HTTP mux with a timing middleware. Every request emits one observation
on the `brain_query_duration_seconds` histogram labeled by
`path` (request Pattern, low cardinality) and `status` (2xx/3xx/4xx/5xx).

Dependency choice: hand-rolled rather than github.com/prometheus/client_golang
because the surface needed is small (one histogram + bucket constants)
and the repo CLAUDE.md keeps deps stdlib + jwx + testify only. ~150 LOC
of code + tests is cheaper than the chart of transitive prometheus deps.

Endpoints:
- GET /metrics  — OpenMetrics text exposition, no auth (cluster-internal)

Wire format pinned by tests in internal/metrics/metrics_test.go. The
ServiceMonitor that drives the kube-prometheus-stack scrape lives in
infra/k3s/apps/supervisor/ (separate commit on mathias/infra).

After this image deploys, the canary alert from
docs/superpowers/specs/2026-05-homelab-architecture-review.md becomes
wireable:

  histogram_quantile(0.95,
    sum(rate(brain_query_duration_seconds_bucket[5m])) by (le))
    > 1.5 * histogram_quantile(0.95,
        sum(rate(brain_query_duration_seconds_bucket[5m] offset 7d)) by (le))

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:13:05 +02:00
Mathias
815739758e feat(vectorstore): re-embed on file mtime > store updated_at (#23)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
Removes the TODO in Sync that left files static after their first embed.
Edits to brain/wiki/ and brain/knowledge/ now surface in subsequent
syncs without manual /backfill-embeddings calls.

Approach
- Store interface: KnownPaths → KnownPathsWithTime returning path →
  updated_at. Callers compare against file mtime to detect edits.
- PGStore: SELECT path, updated_at FROM brain_embeddings.
- Sync groups known chunks by parent path and tracks the EARLIEST
  updated_at per parent. A file is stale when its mtime is after that
  oldest chunk's timestamp — any chunk older than the file means at
  least one chunk hasn't been refreshed since the last edit.
- Stale-path rewrite: delete every old chunk for the parent (handles
  "file shrunk → fewer chunks → orphan rows at higher #NNNN" cleanly),
  then re-chunk + re-embed + re-upsert.

Tests
- New: TestSync_ReembedsFileWhenMtimeNewer — file mtime forced into the
  future vs store updated_at; Sync deletes old chunk + upserts fresh one.
- New: TestSync_SkipsFileWhenMtimeOlder — file mtime backdated; Sync is
  a no-op (no upserts, no deletes).
- Updated: stubStore.known is now map[string]time.Time. A zero value
  resolves to a far-future sentinel so existing "skip if already known"
  tests keep passing without per-test setup.
- pg_test renamed KnownPaths integration → KnownPathsWithTime; asserts
  updated_at is non-zero and within 5s of insert wall-clock.

Backward compat
- brain_embeddings rows pre-dating this change carry valid updated_at
  values (column was always populated via `DEFAULT now()` + ON CONFLICT
  `updated_at = now()`). No migration needed. Live pod will start
  re-embedding any file whose source has been edited since its chunks
  were originally written.

Closes gitea/mathias/hyperguild#23.
2026-05-20 09:50:45 +02:00
Mathias
6f1cb53295 feat(project_create): mirror_to_github opt-in, default false (infra#34 ADR)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
Per the Gitea-as-true-master ADR (infra#34), GitHub mirror is now an
explicit opt-in via mirror_to_github=true. Default (omit / false) provisions
a Gitea repo + staging namespace + experiment-brief issue only — no GitHub
repo, no push-mirror.

Rationale: US cloud providers (Microsoft/GitHub) are subject to CLOUD Act
and NSL. Client code, business logic, and infra-adjacent repos should
never live on US-owned infrastructure. Only open-source projects intended
for public community (hyperguild, gitea-mcp, template-*) should opt in.

Changes
- internal/skills/project/handlers.go
  - createArgs gains MirrorToGitHub bool (json:"mirror_to_github,omitempty").
  - res.GitHubURL is set only when MirrorToGitHub is true; empty string otherwise.
  - Steps 2 (create_github_repo) + 3 (mirror) are wrapped in `if args.MirrorToGitHub`.
  - experimentBrief renders "Gitea-only" line by default and the existing
    "Push-mirror configured" line only on opt-in.
- internal/skills/project/skill.go
  - Tool schema gains mirror_to_github (boolean, default false) with description
    spelling out when to opt in. Tool Description updated to reflect new default.
- internal/skills/project/handlers_test.go
  - Added mirroredArgs() helper (happyArgs + mirror_to_github:true).
  - Tests that exercise the GitHub flow (HappyPath, GitHubExists_Idempotent,
    GitHubFails, NoGitHubClient_DegradedMode, Idempotent_RepoExists,
    MirrorFails, InfraCommitFails) switched to mirroredArgs.
  - Added TestProjectCreate_DefaultSkipsGitHubMirror covering the Gitea-only
    path: 3 gitea-mcp calls, zero GitHub calls, empty github_url, reached=
    [create_repo, infra_commit, issue], body reflects Gitea-only.

Closes gitea/mathias/hyperguild#17. Moves infra#34 acceptance item
"project_create updated: mirror_to_github defaults to false".
2026-05-20 08:35:02 +02:00
Mathias
37fdd33b2d feat(ingestion): chunk markdown before embedding (#38)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
Long markdown files (>~8KB) silently failed to embed because nomic-embed-text
on iguana has a 2048-token context. embed sync logged errors=1 every cycle
with no useful body until #37 added per-item logging — three files exceed
the ceiling: finbert source (8 KB), koala-machine-state (7.1 KB),
litellm-absorption (8.8 KB). Curated knowledge entries should never be
vector-blind.

Approach: chunk-before-embed, no schema change.

vectorstore/chunk.go (new)
- ChunkMarkdown splits at H1/H2 boundaries; sections over maxBytes are
  further split at paragraph boundaries, packing greedily under budget.
- NumberChunks assigns "<parent>#NNNN" storage paths (1-based, zero-padded
  to 4 digits — handles files with up to ~10k sections in stable sort order).
- ParentPath strips the chunk suffix for retrieval-side dedup.

vectorstore/sync.go
- After ChunkMarkdown produces N pieces, each is embedded + upserted as a
  separate brain_embeddings row at "<parent>#NNNN". maxChunkBytes = 4000
  (≈1000 nomic tokens, well under the 2048 ceiling with headroom for
  unicode/code blocks).
- "Already embedded?" check now reduces known paths to parent set via
  ParentPath, so the first chunk hit short-circuits the file.
- Delete walk also reduces via ParentPath; when a parent file disappears,
  every chunk row (and any pre-existing bare-path row, for backward
  compatibility with rows written before this change) gets dropped.

search/search.go
- hybridMerge collapses chunk-path vector hits to parent via ParentPath
  before scope check, RRF accumulation, and hydration. A file with three
  chunk hits returns one result row, not three.

Backward compatibility: pre-existing bare-path rows in brain_embeddings
keep working — ParentPath returns them unchanged, knownParents handles
them as if they were "wiki/foo.md#NNNN" hits, sync skips re-embed, and
search dedup is a no-op for them. No migration required to ship.

Tests:
- chunk_test.go covers short / heading split / oversized section /
  content preservation / chunk numbering / parent-path stripping.
- sync_test.go adds long-file chunking, single-chunk-row short file,
  skip-if-any-chunk-known, delete-all-chunks-of-disappeared-file.
  Existing tests updated for #NNNN paths.
- search_test.go adds chunk-paths-dedupe-to-parent.

Closes gitea/mathias/infra#38.
2026-05-19 21:57:09 +02:00
Mathias
078ec029da fix(ingestion): embed sync also scans brain/knowledge/ + logs per-item errors
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
The embed sync goroutine only walked brain/wiki/. brain/knowledge/ (112
curated entries, per CLAUDE.md the most-important brain content) had zero
coverage in brain_embeddings — vector retrieval was blind to it. Hybrid
BM25 + pgvector retrieval would never surface a curated knowledge entry
via the vector arm.

Extract the per-root walk into a loop over a small subdir list and add
"knowledge" alongside "wiki". scanDirs is package-level so it stays a
single source of truth for what gets embedded.

Also log each failing item's path + error string from StartSync.
Previously only the aggregate count was logged, so a persistent
`errors=1` per cycle was opaque. With per-item warnings, the actual
ollama "input length exceeds the context length" surface immediately.

Refs gitea/mathias/infra#37 (this commit covers the knowledge/ scan
bug; the long-file chunking bug is a separate change.)
2026-05-19 21:27:15 +02:00
Mathias
4af1036423 fix(ingestion): redact password from BRAIN_PG_DSN log line
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
The previous "crude redaction" — pgDSN[:strings.IndexByte(pgDSN+"@", '@')] —
sliced up to the `@` character, which sits *after* the password in a
postgres URL, so the log line included the password in plaintext (caught
on first activation, 2026-05-18 startup log).

Use url.Parse + URL.Redacted() instead. Falls back to "postgres://***"
if parsing fails — we never log a raw DSN.
2026-05-19 13:04:12 +02:00
Mathias
7a13c75655 fix(scripts): brain-embeddings-init.sql psql-level conditionals
All checks were successful
CI / Lint / Test / Vet (push) Successful in 24s
CI / Mirror to GitHub (push) Successful in 3s
CREATE DATABASE doesn't work inside a DO $$ ... $$ block (transactional
restriction). And psql `:'var'` substitutions resolve client-side, so
they can't reach inside a DO block either.

Replace both DO blocks with psql-native idioms:
- `\gexec` for the conditional CREATE DATABASE
- `\if` + `\gset` for the create-or-rotate-password branch on the
  brain_app role

Verified end-to-end on koala postgres18: brain DB created, vector
0.8.1 extension installed, brain_app role login works.
2026-05-18 23:28:56 +02:00
Mathias
57462b52ff feat(brain): hybrid BM25 + pgvector retrieval (opt-in)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 15s
CI / Mirror to GitHub (push) Successful in 3s
Wires nomic-embed-text (iguana ollama) + pgvector on the shared
postgres18 into brain_query / brain_answer via Reciprocal Rank Fusion.
Pure BM25 stays the default; setting BRAIN_PG_DSN and BRAIN_EMBED_URL
together opts in. Setting one without the other is misconfiguration →
exit 1.

New packages:

- internal/embed
  Client.Embed(ctx, text) → []float32 via POST {URL}/api/embed.
  Defaults to nomic-embed-text:latest (768 dim). nil-on-empty-URL so
  callers gate on a single nil check.

- internal/vectorstore
  PGStore wraps a pgxpool against postgres18. Init creates
  brain_embeddings(path PK, vector(768), updated_at) + HNSW cosine
  index idempotently. Upsert / Delete / Search / KnownPaths.
  Sync(brainDir, store, embedder) diffs brain/wiki/ against the store
  and upserts new files / deletes removed ones; StartSync runs it on
  a ticker (default 300s). Integration tests gated by BRAIN_PG_TEST_DSN.

- scripts/brain-embeddings-init.sql
  One-time DBA setup: brain DB, brain_app role, vector extension,
  GRANTs. Idempotent.

Search layer:

- search.QueryOptions gains Vector + Embedder fields.
- QueryContext is the cancellable variant; Query stays for callers.
- When both are set, BM25 (top-N) and pgvector (top-4N) candidates
  merge via Reciprocal Rank Fusion (k=60, Cormack et al. 2009 — no
  tuning knob, robust to scale differences between rankers).
- Vector-only hits are hydrated from disk so callers see uniform
  Result records (path, title, excerpt, wing, hall, score).
- Wing/hall filters still apply to vector candidates via path-prefix.
- On embedder/vector errors the search falls back to BM25 — embedding
  outage degrades quality but doesn't take the brain offline.

MCP wiring:

- mcp.Server.WithHybridRetrieval(v, e) opt-in setter, same shape as
  WithReranker.
- brainQuery and brainAnswer pass the wired vector/embedder through
  to search.QueryContext.

REST:

- POST /backfill-embeddings drives Sync synchronously. Returns
  {added, deleted, errors[]}. 503 when feature is unconfigured.

cmd/server/main.go:

- BRAIN_PG_DSN + BRAIN_EMBED_URL together enable hybrid; one alone
  → exit 1.
- vectorAdapter bridges *PGStore (returns []Hit) to
  search.VectorSearcher (which takes []VectorHit) without either
  package importing the other.
- BRAIN_EMBED_SYNC_INTERVAL (default 300s) controls the background
  Sync ticker.

Backend pivot from Qdrant to pgvector recorded in DECISIONS.md
2026-05-18 (supersedes 2026-04-08): postgres18 already runs in
databases/ ns, Qdrant was never deployed, one engine beats two.

Dependency: github.com/jackc/pgx/v5 — modern, native pgvector via
parametric vector literals.

Tests:
- embed.Client: empty-URL nil, request shape, dimension, upstream
  error propagation, empty-text rejection.
- vectorstore.PGStore: dimension validation (unit); upsert/search/
  KnownPaths (integration, BRAIN_PG_TEST_DSN-gated).
- vectorstore.Sync: adds new files, skips known, deletes
  disappeared, skips _index.md, no-op when nil, collects embedder
  errors.
- search.Query: hybrid promotes vector-only hits via RRF; falls
  back to BM25 on embedder error.

Closes hyperguild#8.
2026-05-18 23:11:25 +02:00
Mathias
a56a4db963 feat(brain_answer): Qwen3-Reranker cross-encoder filter (opt-in)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Adds an opt-in cross-encoder rerank step between BM25 retrieval and LLM
synthesis. With BRAIN_RERANKER_URL set, brain_answer retrieves BM25
top-20, scores each excerpt against the query via Qwen3-Reranker on
Ollama, drops the "no" answers, and forwards up to 5 surviving sources
to the LLM. Unset, behaviour is unchanged (BM25 top-10 → LLM).

The reranker is a *filter*, not a re-ranker: Qwen3-Reranker emits a
binary yes/no token under its native chat template, and ties within the
"yes" set are broken by BM25 rank — what got retrieved first stays
ahead.

New package ingestion/internal/reranker:
- Client with URL, Model, HTTP fields.
- New(url, model) returns nil on empty url so callers can treat
  "feature disabled" as a single nil check.
- Score(ctx, query, docs) issues one /api/generate call per doc using
  the Qwen3-Reranker yes/no chat template (verbatim, because the model
  was trained on this exact wording). Parses the first non-think token.

Wiring:
- mcp.Server gains a WithReranker fluent setter to keep NewServer
  signature stable.
- brain_answer's BM25 limit jumps to 20 only when a reranker is wired,
  to give the filter something to do.
- cmd/server/main.go reads BRAIN_RERANKER_URL (+ optional
  BRAIN_RERANKER_MODEL, default dengcao/Qwen3-Reranker-0.6B:F16).

Tests cover: nil-on-empty-url, ordered yes/no scoring, request shape
(model, prompt contents, yes/no template), ambiguous response → 0,
empty doc slice, upstream-error propagation, plus an end-to-end
brain_answer integration that proves only the relevant note reaches the
LLM when noise.md is rejected.

Closes hyperguild#7.
2026-05-18 22:55:46 +02:00
Mathias
58c57412a9 feat(brain-mcp): OAuth 2.0 client_credentials flow for claude.ai
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s
Adds a minimal RFC 8414 + RFC 6749 client_credentials flow so claude.ai's
custom-MCP integration (no static-Bearer field in the UI) can exchange a
client_id + client_secret pair for the existing BRAIN_MCP_TOKEN and use
it as a Bearer on /mcp. No JWTs, no refresh, no expiry — the rest of
the auth middleware is unchanged.

New package ingestion/internal/oauth:
- MetadataHandler(issuer): serves /.well-known/oauth-authorization-server
  with grant_types=[client_credentials] and both
  token_endpoint_auth_methods (post + basic).
- TokenHandler(cfg): serves /oauth/token. Validates client_id and
  client_secret via constant-time compare; returns BRAIN_MCP_TOKEN as
  access_token. RFC 6749 §5.2 error JSON on bad grant / bad creds.

Wiring in cmd/server/main.go: opt-in by setting both OAUTH_CLIENT_ID and
OAUTH_CLIENT_SECRET. Setting only one is misconfiguration → exit 1.
Mounts both endpoints with no auth; MCP_RESOURCE_URL supplies the
issuer.

Also pivots issue #8's vector backend from Qdrant to pgvector (see
DECISIONS.md 2026-05-18) — Qdrant was never deployed and postgres18 with
pgvector already runs as the project default; supersedes 2026-04-08 for
this use case.

Tests cover post-auth, basic-auth, wrong secret, bad grant, GET
rejection, malformed Basic header, and Basic without colon.

Closes hyperguild#5.
2026-05-18 22:21:54 +02:00
Mathias
ddd07ae7eb feat(brain): cross-wing tunnels — bidirectional wikilinks + auto-detect
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s
Adds the `brain_tunnel` MCP tool and auto-tunnel behaviour for
`brain_write`, so concepts that appear in multiple wings become
navigable from any of them.

New surface in package brain:
- WriteTunnel(brainDir, src, tgt) — appends a `## See also` bidirectional
  wikilink between two notes in different wings. Idempotent (link not
  duplicated on re-call) and reuses an existing See also section.
- DetectTunnels(brainDir, content) — walks brain/wiki/, returns
  TunnelCandidates for notes whose title appears in content. Tags
  whole-word case-insensitive hits as Exact=true and substring-only hits
  as Exact=false.
- AutoTunnel(brainDir, src, content) — wraps DetectTunnels: writes
  cross-wing exact matches, stages fuzzy matches into
  brain/raw/tunnel-candidates-<YYYY-MM-DD>.md for human review.

MCP wiring:
- `brain_tunnel` tool: explicit manual link (source, target).
- `brain_write` with wing+hall now triggers AutoTunnel on the new
  content. Failures are logged and never abort the primary write.

readTitleAndCreated also humanises the slug fallback (hyphens → spaces)
so titleless notes participate in content matching.

Closes hyperguild#16.

Tests: idempotency, same-wing rejection, missing-note rejection,
See-also reuse, exact/fuzzy detection, slug fallback, MCP tool happy
path, auto-tunnel hook (cross-wing exact → linked; same-wing → skipped;
fuzzy → candidates file).
2026-05-18 21:32:49 +02:00
Mathias
61b6247df9 fix(brain-mcp): static Bearer short-circuits before OAuth challenge
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s
Reorders BearerAuth so a valid BRAIN_MCP_TOKEN match wins instantly and
never emits WWW-Authenticate. Adds RFC 9728 resource_metadata challenge
header on 401 (only when MCP_RESOURCE_URL is configured) so claude.ai's
OAuth-discovery path still works.

Why: claude CLI on koala/flamingo with `.mcp.json` `Authorization: Bearer
$BRAIN_MCP_TOKEN` was being kicked into RFC 7591 dynamic client
registration against Dex (static-only) and dying. Cause was the auth
middleware running JWT validation first and emitting an OAuth challenge
on the fall-through 401 even when the caller had a valid static token.
Inverting the precedence and gating the challenge on resourceMetadataURL
keeps the LAN/Tailscale CLI path silent and only invites OAuth discovery
on actually-unauthenticated requests.

Regression guards in the test file:
- valid static Bearer 200 has no WWW-Authenticate
- 401 with resourceMetadataURL set carries the challenge
- 401 with empty resourceMetadataURL emits no challenge

Closes hyperguild#9 in code. Live verification (claude CLI on koala
listing brain tools) blocked on ingestion image rebuild + redeploy.
2026-05-18 21:00:05 +02:00
Mathias
75685e7b67 feat(brain): structured wing/hall taxonomy + obsidian-compatible layout
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
Adds a two-dimensional address (wing, hall) to brain notes. A wing is a
topic domain (e.g. jepa-fx, hyperguild); a hall is one of a closed
vocabulary of memory types (facts, decisions, failures, hypotheses,
sources). Notes route to brain/wiki/<wing>/<hall>/<slug>.md with
wing/hall/created_at YAML frontmatter, making the directory a valid
Obsidian vault.

Changes:
- new package ingestion/internal/brain (NotePath, ValidHalls, Sanitise,
  BuildWingIndex, BuildAllWingIndexes)
- api.WriteNote refactored to WriteNoteOptions; wing+hall routes to
  brain/wiki/, otherwise falls back to brain/knowledge/ (legacy)
- search.Query → QueryOptions with optional Wing/Hall filtering; Result
  carries wing/hall extracted from frontmatter or path segments
- MCP tools brain_write and brain_query gain optional wing/hall params
  (hall enum-validated); new brain_index tool regenerates _index.md MOC
- POST /index REST endpoint mirrors brain_index
- brain_write auto-rebuilds the wing's _index.md after a wing+hall write
- scripts/migrate-brain-halls.sh migrates flat brain/wiki/{concepts,entities}/
  into the new layout (dry-run by default, --commit applies)

All existing tests pass; new tests cover wing/hall write routing, scope
filtering, invalid hall rejection, _index.md generation, and migration
script paths.

Closes hyperguild#1.
2026-05-18 20:47:08 +02:00
Mathias
fe18e4ee77 test(routing): de-flake TestRoutingPodEndToEnd
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
- Random port via net.Listen(":0") replaces hardcoded 33310 (was the
  primary failure mode under parallel test load).
- Bump waitForPort deadline 5s → 30s — `go build` under -race can exceed
  5s on a loaded machine.
- Replace osPath() (always returned empty PATH because exec.Command("env").Env
  is the *child's* env, not the parent's) with explicit PATH+HOME via
  os.Getenv. Don't inherit full env: would leak ROUTING_MCP_TOKEN from the
  parent shell and flip the routing pod into auth-required mode, breaking
  the test.

Closes #15. Verified: 10 cold-cache test runs pass, 3 consecutive task check
runs pass.
2026-05-18 20:00:18 +02:00
Mathias
937355cabe fix(project_create): commit staging namespace directly to infra main
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s
Drops the intermediate `staging/<name>` branch so Flux begins reconciling the
namespace within ~60s of `project_create` instead of waiting on a human PR
merge. Consistent with project-wide trunk-based development.

Rationale: ADR 2026-05-18 in DECISIONS.md.

Closes hyperguild#14 (item 1). Item 2 (GITEA_MCP_TOKEN in SOPS) verified
already-present in infra@408a527 secrets.enc.yaml.

Note: TestRoutingPodEndToEnd is failing on main pre-existing this commit
(context deadline waiting for port 33310 in <5s). Not caused by this change;
project skill tests pass. To track in a separate issue.
2026-05-18 17:20:53 +02:00
Mathias
5950ef5f0f feat(mcpclient): fail-fast on empty bearer token
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
mcpclient.New previously accepted an empty token and silently omitted
the Authorization header at request time. When the env var sourcing
the token was missing from a Kubernetes Secret (envFrom doesn't warn
on missing keys), this surfaced as an opaque 401 from the upstream
MCP server with no log trail — see hyperguild #13 and brain entry
"mcpclient-empty-token-silent-401-envfrom-missing-key".

mcpclient.New now returns ErrTokenRequired when token is empty.
The routing pod's project_create init checks the error and exits
with a clear message pointing at routing-secrets, turning a runtime
401 storm into a startup crashloop the operator can fix immediately.

Tests pass a dummy "test" token (httptest servers don't enforce
bearer auth, so any non-empty value works). Added a regression
test asserting empty-token construction returns ErrTokenRequired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:09 +02:00
Mathias
a220fcaf2b feat(routing): create GitHub destination repo before configuring push-mirror
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
Gitea's push-mirror cannot push to a non-existent remote — it just
runs 'git push' against whatever URL it's given. So a project_create
flow that only configures the mirror leaves the GitHub side as an
unfulfillable URL.

New internal/githubclient package: single-purpose client that POSTs
/user/repos to create an empty private repo (auto_init=false so the
first mirror push doesn't conflict with a generated README). Treats
422 'name already exists' as idempotent success via ErrAlreadyExists.
401/403 are surfaced as 'PAT missing repo scope or invalid' so the
operator sees the real cause instead of a vague upstream error.

Skill wiring:
- New stepCreateGitHub between stepCreateRepo and stepMirror in the
  orchestrator.
- Skipped entirely when Config.GitHub is nil (degraded mode — the
  routing pod runs without GITHUB_PAT, mirror config still lands,
  but the actual sync to github fails until the repo exists).
- cmd/routing/main.go constructs githubclient.New(GitHubPAT) only
  when the PAT is set; the skill receives nil otherwise.

Tests:
- happy path: fake github 201 + assertions that the 'reached' array
  is [create_repo, create_github_repo, mirror, infra_commit, issue].
- github 422 already-exists: idempotent, all gitea steps still run.
- github 401: returns failed_step=create_github_repo, no mirror or
  later steps.
- degraded mode (Config.GitHub nil): reached omits create_github_repo,
  rest of the flow runs unchanged.

Updated existing tests to read [skill, gh] from newSkill instead of
just skill, and adjusted reached-array expectations to include the
new step.

Tracks #10.
2026-05-18 13:42:03 +02:00
Mathias
d1c8e3396f fix(cd): drop retired supervisor build, add routing rollout verification
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
Plan 7 (2026-05-12) retired the supervisor pod, deleted cmd/supervisor/
and the root Dockerfile, but cd.yml still tried to:

- buildctl a supervisor image using the (non-existent) root Dockerfile
- sed gitea.d-ma.be/mathias/supervisor: in k3s/apps/supervisor/deployment.yaml
  (also non-existent — k3s/apps/supervisor/ only ships ingestion-* files now)
- wait for and rollout-verify a supervisor Deployment that no longer exists

Result: every CD run since the retirement has been failing at 'Build and push
supervisor image', leaving ingestion + routing un-deployed despite the binaries
being built. The routing pod was last deployed at sha 189ff89c (weeks stale).

This commit:
- Removes the supervisor build step and supervisor sed/git add lines.
- Adds 'Wait for Flux to apply new routing image' + 'Verify routing rollout'
  steps that mirror the ingestion equivalents, so failures land loudly rather
  than 5 min later when something tries to call the new tool.
- Updates the chore(deploy) commit message to 'ingestion+routing' to match
  reality.

Unblocks deployment of feat: project_create (#10).
2026-05-18 11:48:57 +02:00
Mathias
3b79311fdd feat(routing): project_create MCP tool — gitea-first new-project pipeline (#10)
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
Adds the project_create tool to the routing pod that automates the
"new project" bootstrap end-to-end from claude.ai. Gitea-first
architecture: GitHub receives the repo only via push-mirror, never
via a direct GitHub API call from this server.

Four sequential calls to the gitea-mcp server (configured via
GITEA_MCP_URL):

  1. create_project_from_template — Gitea repo from
     template-go-{agent,web} per the 'stack' arg
  2. repo_mirror_push (action=add) — push-mirror to
     github.com/<GITHUB_OWNER>/<name>.git, interval 8h, sync_on_commit
  3. file_write_branch — k3s/staging/<name>/namespace.yaml committed
     on a staging/<name> branch in the infra repo
  4. issue_create — experiment brief (hypothesis + description + stack
     + provisioning log) on the new repo, returns the issue_url

Returns gitea_url, github_url, issue_url, next_steps. The next_steps
string is the exact shell sequence the operator runs locally to
clone, scaffold via local-dev 'task new-project', and push.

Idempotency: create_project_from_template + repo_mirror_push +
file_write_branch all return JSON-RPC code -32003 (Conflict) when
their target already exists; the orchestrator swallows the conflict
and continues. Re-running on an existing repo restates the brief in
a fresh issue.

Error handling: on any non-conflict downstream failure the response
returns {reached: ["<step>",...], failed_step: "<step>"} alongside
a JSON-RPC error. No rollback — partial state stays so the operator
can resume manually.

New env vars (all optional except GITEA_MCP_URL):
  GITEA_MCP_URL    enables the tool
  GITEA_MCP_TOKEN  bearer auth for gitea-mcp
  GITEA_OWNER      default mathias
  GITHUB_OWNER     default mathiasb
  INFRA_REPO       default infra
  GITHUB_PAT       repo scope, used as mirror remote_password; never logged

Without GITEA_MCP_URL set, the tool is not registered and the
routing pod starts normally (degrades open).

internal/mcpclient/: new minimal JSON-RPC tools/call client with
bearer auth, used by project_create. Unwraps MCP's
content[0].text envelope and surfaces typed errors via mcpclient.Error.

Tests: table-driven against an httptest fake gitea-mcp covering happy
path (4-step success + correct PATCH-style arg shapes), idempotent
repo-exists, mirror failure (partial-success response with reached=
[create_repo] + failed_step=mirror), infra-commit failure (reached up
to mirror + failed_step=infra_commit), and validation errors.

Closes #10
2026-05-18 11:44:39 +02:00
Mathias
7baf8d7e7a chore: re-sync context adapters from updated root AGENT.md 2026-05-18 11:44:02 +02:00
Mathias Bergqvist
a8de04c7b6 docs: update canonical PROJECT.md for completed 7-plan migration
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
Updates MCP endpoints section: supervisor retired, brain gets HTTPS
domain + Dex JWT auth + brain_answer/brain_classify. Regenerate all
derived adapter files via context:sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:53:46 +02:00
Mathias Bergqvist
87cf9d0afc docs: update CLAUDE.md and DECISIONS.md for completed 7-plan migration
Some checks failed
CI / Mirror to GitHub (push) Has been cancelled
CI / Lint / Test / Vet (push) Has been cancelled
Reflects Plan 7 (supervisor retirement) and brain_answer/brain_classify
addition. Supervisor MCP endpoint removed; brain now exposes HTTPS domain
with Dex JWT auth. Routing decisions documented for LLM berget→iguana chain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:53:08 +02:00
Mathias Bergqvist
46adaf2148 chore(mcp): remove supervisor entry from .mcp.json
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 3s
2026-05-12 14:49:46 +02:00
Mathias Bergqvist
c11763472c feat(plan7): retire supervisor pod — delete cmd/supervisor, tdd/spec skills, Dockerfile
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Removes the supervisor binary and its two exclusive skill packages (tdd,
spec) now that all functionality is covered by SKILL.md files, the routing
pod, and the brain MCP. Routing pod reuses review/debug/retrospective/trainer
skill packages which are intentionally preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:18:30 +02:00
Mathias Bergqvist
189ff89c34 feat(brain): add brain_answer and brain_classify MCP tools
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s
Adds two new LLM-backed MCP tools to the ingestion service:

- brain_answer(query): BM25 retrieval + LLM synthesis → answer + sources
- brain_classify(text): classifies doc into type/title/tags via LLM

Adds llm.Router for primary→fallback routing (berget.ai → iguana).
Wired via BRAIN_LLM_PRIMARY_URL/BRAIN_LLM_FALLBACK_URL env vars;
no-op when unset so existing deployments are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:06:17 +02:00
Mathias Bergqvist
c7e0192486 feat(auth): add Dex JWT middleware to supervisor, routing pod, and brain MCP
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 3s
Closes #6 on gitea.d-ma.be/mathias/hyperguild.

Dex is deployed at auth.d-ma.be. All three MCP servers now accept JWTs
issued by Dex in addition to static bearer tokens, enabling claude.ai
OAuth 2.0 integration without abandoning backward-compat CLI auth.

Changes:
- internal/auth/: new Validator (JWKS auto-refresh via lestrrat-go/jwx/v2),
  ProtectedResourceHandler (RFC 9728 /.well-known/oauth-protected-resource)
- internal/mcp/Server: adds optional *auth.Validator; checkAuth tries JWT
  first, then static token fallback; both-nil = auth disabled (unchanged default)
- cmd/supervisor, cmd/routing: construct Validator from DEX_ISSUER_URL +
  MCP_AUDIENCE env vars; register protected-resource handler when set
- ingestion/internal/auth/: same Validator + handler (separate module)
- ingestion/internal/mcp/BearerAuth: same JWT-or-static chain
- ingestion/cmd/server: same wiring pattern

New env vars (all optional; absent = static-token-only, same as before):
  DEX_ISSUER_URL   — Dex issuer URL (e.g. https://auth.d-ma.be)
  MCP_AUDIENCE     — expected aud claim (e.g. brain, supervisor)
  MCP_RESOURCE_URL — resource identifier for RFC 9728 metadata response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:10:05 +02:00
1c3c9de550 Merge pull request 'refactor(routing): rename local/claude to fast/thinking model pair' (#4) from agent/thinking-fast-routing into main
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
2026-05-08 14:43:29 +00:00
d0edc1a725 Merge pull request 'chore(mcp): switch MCP endpoints to HTTPS domain URLs' (#3) from agent/mcp-domain-urls into main
Some checks failed
CI / Lint / Test / Vet (push) Has been cancelled
CI / Mirror to GitHub (push) Has been cancelled
2026-05-08 14:43:18 +00:00
Mathias Bergqvist
5b207425ed refactor(routing): rename local/claude to fast/thinking model pair
All checks were successful
CI / Lint / Test / Vet (pull_request) Successful in 10s
CI / Mirror to GitHub (pull_request) Has been skipped
The routing decision is about reasoning capacity, not cost or provider.
Fast model (koala/qwen35-9b-fast) handles high-pass-rate calls; thinking
model (iguana/gemma4-26b) handles low-pass-rate calls. Removes the
implicit Anthropic dependency from the routing pod — both models go
through LiteLLM.

Renames: HYPERGUILD_LOCAL_MODEL → HYPERGUILD_FAST_MODEL,
HYPERGUILD_CLAUDE_MODEL → HYPERGUILD_THINKING_MODEL,
Router.LocalModel → FastModel, Router.ClaudeModel → ThinkingModel,
log decision "claude_fallback" → "thinking_fallback".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:39:42 +02:00
Mathias Bergqvist
cb51ff7ba1 chore(mcp): switch MCP endpoints to HTTPS domain URLs
All checks were successful
CI / Lint / Test / Vet (pull_request) Successful in 10s
CI / Mirror to GitHub (pull_request) Has been skipped
Brain and supervisor now behind NPM with Let's Encrypt. Use canonical
hostnames (brain-mcp.d-ma.be, supervisor-mcp.d-ma.be) over NodePorts so
connections work across networks without Tailscale for DNS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:10:25 +02:00
Mathias Bergqvist
43a8255272 fix(mcp): add SSE GET handler for streamable HTTP transport
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
claude.ai probes with GET before initialize; without this the supervisor
returned application/json parse error instead of text/event-stream, causing
"Couldn't reach the MCP server" in the claude.ai connector setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:27:56 +02:00
Mathias Bergqvist
78be3d1f9c fix(ingestion): support GET/SSE on /mcp endpoint for claude.ai compatibility
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
2026-05-07 23:20:47 +02:00
Mathias Bergqvist
7139a3ca74 ci: add environment gate and Flux rollout verification to cd pipeline
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
Aligns hyperguild's cd.yml with the cobalt-dingo reference pattern:
- Add environment: staging to the deploy job
- Add Flux reconcile trigger after infra repo push
- Add polling wait for supervisor and ingestion image tags to propagate
- Add rollout status verification for both deployments with failure
  diagnostics (pod status, events, describe)
2026-05-07 21:52:52 +02:00
Mathias Bergqvist
c509ae2a5f refactor(ingestion): use strings.CutPrefix for explicit Bearer scheme check 2026-05-07 21:02:14 +02:00
Mathias Bergqvist
228ee57d4c feat(ingestion): add bearer token auth middleware for MCP endpoint 2026-05-07 20:58:16 +02:00
Mathias Bergqvist
bee4bb3c1f chore(routing): pre-merge cleanup — Plan 7 reminders, code_review→review, operator note
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 23:22:15 +02:00
Mathias Bergqvist
d72454d929 docs(routing): document Mode 2 routing pod + env vars
Add routing pod to README architecture diagram and env vars table.
Add routing MCP endpoint to .context/PROJECT.md. Regenerate derived
context adapters via task context:sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 23:00:48 +02:00
Mathias Bergqvist
cf94d14922 chore(routing): drop unused BIN_PID assignment in smoke script 2026-05-05 22:56:44 +02:00
Mathias Bergqvist
78a43d6a42 test(routing): live-contract smoke target
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:52:23 +02:00
Mathias Bergqvist
ca933eef46 build(routing): Dockerfile + CD workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 07:19:18 +02:00
Mathias Bergqvist
88782de07c feat(hyperguild): mode client-local writes routing headers
Plan 6 is now deployed; replace the _routing_pending placeholder in the
routing MCP entry with a real headers block carrying X-Hyperguild-Mode:
client-local. The pod treats absent or unknown values as client-local,
so this is forward-compat for future modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 07:13:24 +02:00
Mathias Bergqvist
083c2d7db9 feat(routing): cmd/routing binary
Wires Config → LiteLLMExecutor → Router → four skills (review, debug,
retrospective, trainer) → Registry → MCP server with bearer auth and
/healthz. Each skill's CompleteFunc is wrapped so the Router decides
local-vs-Claude per call and logs every decision to the brain /mcp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 23:43:59 +02:00
Mathias Bergqvist
751f410ca6 test(routing): pin tool-schema parity with supervisor
Captures the four routed skills' (review, debug, retrospective, trainer)
tool definitions as a JSON snapshot and asserts the routing pod's registry
advertises byte-equal schemas. A deliberate schema change fails this test,
requiring an intentional snapshot update in lockstep with consumers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:59:06 +02:00
Mathias Bergqvist
3a99d5e20e refactor(routing): surface logger errors via slog.Warn
Replace silent `_ = r.Logger.LogDecision(...)` discards with an
if-err check that emits slog.Warn on failure. A brain outage now
produces a visible warn line instead of swallowing the telemetry
error entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:55:35 +02:00
Mathias Bergqvist
9a258ca32a feat(routing): router dispatch wrapper
Composes Fetcher + Policy + Logger + CompleteFunc into a single Run method.
Falls open to Claude on local-model errors; defaults to local when brain is
unreachable. Skill packages will receive Router.Run as their CompleteFunc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:51:01 +02:00
Mathias Bergqvist
2a5a74f7c0 feat(routing): decision logger via brain MCP session_log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:52:09 +02:00
Mathias Bergqvist
d40a5ac890 test(routing): cover TTL expiry in fetcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:50:01 +02:00
Mathias Bergqvist
b77820534a feat(routing): pass-rate fetcher with TTL cache
HTTP client that calls GET /pass-rate?skill=X&window=Y on the brain pod.
Caches *float64 results (including nil) per-skill for the configured TTL
(default 60s). On non-200 or network error returns (nil, err) so the
upstream router can fall through to default-to-local.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:46:11 +02:00
Mathias Bergqvist
db64ecb1d9 feat(routing): canonical request hash
SHA-256 of (system, user) joined with 0x00 separator, truncated to
uint64. Drives deterministic sample-band routing: identical prompt pair
→ same hash → same local-vs-Claude decision on every call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:41:42 +02:00
Mathias Bergqvist
ea29e5ebb8 feat(routing): decision policy
Pure-function Policy{Floor,Ceil} with Decide(*float64, uint64) Decision.
Rules in priority order: nil → local; ≥floor → local; <ceil → claude;
sample band → low bit of requestHash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:36:59 +02:00
Mathias Bergqvist
ccf080db59 refactor(routing): clarify Floor/Ceil semantics + extend test coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:34:22 +02:00
Mathias Bergqvist
69c038478b feat(routing): RoutingConfig + LoadRouting
Typed config struct and env parser for the routing pod. Kept separate
from the supervisor Config to avoid forcing routing fields onto the
supervisor and vice versa. Uses the existing envOr helper from config.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:25:31 +02:00
Mathias Bergqvist
b6bcc93048 docs(plan6): implementation plan for Mode 2 routing pod
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
14 TDD-shaped tasks across two worktrees: hyperguild for code
(internal/routing package, cmd/routing binary, Dockerfile, CD
workflow, mode template, smoke test, docs) and infra for the
k3s manifests (deployment, service, nodeport, SOPS-encrypted
secret). Plan 7 amendment baked in: internal/skills/{review,
debug,retrospective,trainer} survive Plan 6 — Plan 7 only
deletes tdd, spec, and the supervisor binary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:53:03 +02:00
Mathias Bergqvist
51e01233a4 docs(plan6): spec for Mode 2 routing pod
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Drafted via superpowers:feature-spec. Plan 6 of 7 in the skill
migration. Surface frozen at 4 cost-routable skills (code_review,
debug, retrospective, trainer); LiteLLM proxies model choice; pass-
rate drives the route decision with default-to-local plus an env
kill switch for the empty-data window. Plan 7 amendment baked in:
internal/skills/{review,debug,retrospective,trainer} survive Plan 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:29:26 +02:00
Mathias Bergqvist
f49850d23b chore(mcp): require bearer token for supervisor MCP
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
The pod now enforces SUPERVISOR_MCP_TOKEN; this matches the .mcp.json
header so a Claude Code session in this repo authenticates correctly.
Token comes from the operator's shell env, not the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:57:14 +02:00
Mathias Bergqvist
928f23ab1b feat(mcp): optional bearer-token auth via SUPERVISOR_MCP_TOKEN
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Enables exposing the supervisor MCP via Tailscale Funnel for claude.ai
custom-connector tests. Auth is opt-in: empty SUPERVISOR_MCP_TOKEN
preserves the existing unauthenticated behavior for tailnet-internal
callers and local dev.

When the token is set, every request must carry
"Authorization: Bearer <token>" or it is rejected with HTTP 401 and a
JSON-RPC -32001 error. Comparison uses crypto/subtle.ConstantTimeCompare;
the token value and the supplied header are never logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 07:31:29 +02:00
Mathias Bergqvist
1b9c4905a5 docs(plans): patch Plan 5 — test helper sessions/ subdir + sessionlog docstring step
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 4s
Two corrections applied during Plan 5 execution:

  - Task 2 test helper writeSession now joins a sessions/ subdir so it
    matches the handler's <brainDir>/sessions/*.jsonl scan path.
    (The original heredoc would have produced 0 records in tests.)

  - Task 6 grew a Step 1.5 to update the session_log MCP tool's
    final_status description, picking up a spec requirement that
    didn't translate into a task in the original plan.
2026-05-03 22:59:21 +02:00
Mathias Bergqvist
400025715a feat: pass-rate /pass-rate endpoint and CLI — Plan 5 of hyperguild migration
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 3s
Adds the read side of pass-rate logging that consumes the SKILL.md
instrumentation landed in the local-dev companion merge:

  - GET /pass-rate?skill=X&window=Y on the brain pod (ingestion module)
    walks brain/sessions/*.jsonl, normalizes legacy {ok,error,skipped}
    to canonical {pass,fail,skip}, returns aggregate counts plus
    pass_rate (null when pass+fail == 0).

  - hyperguild brain pass-rate <skill> [--window 7d] [--json] CLI
    subcommand (third nested verb under brain).

  - session_log MCP tool docstring updated to lead with the new
    pass|fail|skip vocabulary; legacy values still accepted by both
    the tool's loose validator and the aggregator's normalization.

This is the brain HTTP REST API's first GET endpoint — pure reads
follow REST semantics; legacy POST routes (query, write, ingest, etc.)
all take JSON bodies. Future read endpoints SHOULD use GET.

Plan 6 routing pod is the consumer; one week of usage data between
this merge and Plan 6 deploy will mean Plan 6 lands on real numbers.

Plan: docs/superpowers/plans/2026-05-03-pass-rate-logging.md
Spec: docs/superpowers/specs/2026-05-03-pass-rate-logging-design.md
2026-05-03 22:58:24 +02:00
Mathias Bergqvist
986e3e1d12 docs(hyperguild): document brain pass-rate subcommand and /pass-rate endpoint
Adds pass-rate to the CLI README's subcommand block. Updates CLAUDE.md
to note the new /pass-rate endpoint alongside the existing brain
HTTP REST API surface. Updates the session_log MCP tool's
final_status description to reflect the new pass|fail|skip vocabulary
introduced by Plan 5's SKILL.md instrumentation; the aggregator
still accepts legacy ok|error|skipped values for backwards compat.
2026-05-03 22:55:35 +02:00
Mathias Bergqvist
593d1a4c6d feat(hyperguild): brain pass-rate subcommand
Adds 'hyperguild brain pass-rate <skill> [--window 7d] [--json]'
calling the new /pass-rate endpoint. Human output:
  tdd: 47 / 50 = 94% (window: 7d)
or 'no data (window: 7d)' when pass_rate is null.

PassRateResult mirrors the response envelope; PassRate is *float64
so null is preserved across decode.
2026-05-03 22:44:04 +02:00
Mathias Bergqvist
417bf224eb feat(brain): register GET /pass-rate route
Adds the route entry alongside the existing POST routes. Note: this
is the brain HTTP REST API's first GET endpoint — it follows REST
semantics for pure reads, while the legacy POST routes (query, write,
ingest, etc.) all take JSON bodies. Future read endpoints SHOULD use
GET; future write endpoints continue with POST.
2026-05-03 22:40:31 +02:00
Mathias Bergqvist
37dbd22eff feat(brain): /pass-rate aggregator and handler
Adds a new HTTP GET handler at the ingestion pod that walks
brain/sessions/*.jsonl, filters by skill name and timestamp window
(default 7d, accepts Nh and Nd), normalizes legacy status vocabulary
(ok->pass, error->fail, skipped->skip), and returns aggregated counts
plus pass_rate.

Pass rate is null when pass+fail == 0, distinguishing 'no data' from
'always passes'. Plan 6 routing pod will check for null before
making decisions.

Route registration in cmd/server/main.go lands in a follow-up commit.
2026-05-03 22:37:41 +02:00
Mathias Bergqvist
cbf5cab5e7 docs(plans): pass-rate logging implementation plan — Plan 5
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
Seven-task plan spanning two worktrees: Phase 1 (local-dev) for
SKILL.md instrumentation (pilot tdd, then rollout to 6 binary-outcome
skills), Phase 2 (hyperguild) for the /pass-rate endpoint + CLI
subcommand. Uses GET for the pure-read endpoint (REST semantics)
despite the brain API's POST-everywhere precedent for legacy
endpoints.

Pilot-then-rollout structure: tdd SKILL.md lands first; the
endpoint TDD task naturally exercises the new logging contract
as the dogfood validation step before the other 6 skills get
instrumented.
2026-05-03 22:29:56 +02:00
Mathias Bergqvist
af52f501fe docs(specs): pass-rate logging design — Plan 5 of hyperguild migration
Skill instrumentation pattern, brain /pass-rate HTTP endpoint, and
optional hyperguild CLI subcommand for shell access. Pilot with tdd
SKILL.md, then roll out to 6 binary-outcome skills (code-review,
debug, feature-spec, session-retrospective, trainer, spec-driven-dev).

Decisions: SKILL.md as source of truth for the logging contract;
on-demand aggregation from JSONL (no materialized counters until
proven necessary); pass|fail|skip vocabulary forward, with
ok|error|skipped accepted by the read-side aggregator for backwards
compat.

Seven success criteria, ten out-of-scope items, six risks.
2026-05-03 22:23:28 +02:00
Mathias Bergqvist
b3b1fde825 feat: hyperguild CLI — Plan 4 of hyperguild migration
All checks were successful
CI / Lint / Test / Vet (push) Successful in 15s
CI / Mirror to GitHub (push) Successful in 3s
A small Go binary at cmd/hyperguild/ providing four subcommands:

  - tier              probe Anthropic + LiteLLM, print operating tier
  - brain query       BM25 search the brain via HTTP REST
  - brain write       write a knowledge entry from stdin
  - mode <name>       bootstrap .mcp.json (cloud|client-local|sovereign)

Reuses internal/tier verbatim. Brain access uses the HTTP REST API
at ${BRAIN_URL:-http://koala:30330} with POST /query and POST /write.
Mode templates include a Plan 6 routing-pod placeholder (client-local)
and a Crush-fallback note (sovereign).

Stdlib only — flag, net/http, encoding/json. 32 unit tests via
httptest.Server fakes; smoke-tested end-to-end against the live brain.

Replaces the supervisor's tier MCP. Plans 5 (pass-rate logging) and 6
(routing pod) build on the brain client and tier subcommand.

Plan: docs/superpowers/plans/2026-05-03-hyperguild-cli.md
Spec: docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md
2026-05-03 22:06:48 +02:00
Mathias Bergqvist
ab4cfaaeb7 fix(hyperguild): remove redundant subcommand prefixes from error messages
dispatch() already prefixes errors with 'hyperguild <subcmd>: ', so
handlers re-prefixing with their own name produced stuttered output
like 'hyperguild brain: brain query: topic required'. Strip the
redundant prefixes from the seven affected errors.New / fmt.Errorf
calls in brain.go and mode.go.

Also fix the LITELLM_BASE_URL usage text — it's optional (empty
falls through to airplane tier), not required, matching the
README.
2026-05-03 22:06:33 +02:00
Mathias Bergqvist
eb844edb29 docs(specs): correct brain Query method (POST not GET)
The brain HTTP REST /query endpoint accepts POST with JSON body
{query, limit}, not GET with URL query string. Surfaced empirically
during Plan 4 Task 7 smoke testing. Spec text now matches the
implementation.
2026-05-03 22:00:05 +02:00
Mathias Bergqvist
317ec20392 fix(hyperguild): brain Query uses POST /query with JSON body
The brain HTTP REST /query endpoint accepts POST with JSON
{query, limit}, not GET with URL query string. Surfaced by
Task 7 smoke testing — GET returned 405 Method Not Allowed.

The response shape ({results:[...]}) is unchanged; only the
request side flips to POST + JSON body. brainClient.Write was
already using POST + JSON body and is unaffected.

Tests updated to assert POST + JSON body on the Query path.
2026-05-03 21:59:17 +02:00
Mathias Bergqvist
eab8775f5f feat(hyperguild): README + Taskfile integration
Adds cmd/hyperguild/README.md (subcommands, env vars, install path)
and three Taskfile targets:

  task hyperguild:dev     — go run from source
  task hyperguild:build   — build into ./bin/hyperguild
  task hyperguild:install — go install into $GOBIN

Concludes Plan 4 of the hyperguild migration. The binary replaces
the supervisor's tier MCP and surfaces brain HTTP REST access plus
mode bootstrap to shell pipelines and ad-hoc agent prompts.
2026-05-03 21:56:20 +02:00
Mathias Bergqvist
a0d0914a85 feat(hyperguild): mode subcommand
'hyperguild mode <cloud|client-local|sovereign>' writes a per-mode
.mcp.json template:

  - cloud:        brain MCP only
  - client-local: brain + routing placeholder with _routing_pending
                  pointer to Plan 6
  - sovereign:    brain only + top-level _mode_note explaining Crush
                  is primary; .mcp.json is Claude Code fallback

Default output is ./.mcp.json; --out overrides; --force overwrites.
Brain URL sourced from BRAIN_URL (default http://koala:30330) so the
template stays in lockstep with the user's brain host.

All three subcommands now wired; notYet/errNotImplemented removed
from main.go.
2026-05-03 21:50:05 +02:00
Mathias Bergqvist
8f9642df69 feat(hyperguild): brain write subcommand
Reads markdown from stdin, POSTs to the brain's /write endpoint with
type + slug, prints the resulting path. Pairs with 'brain query' for
shell-friendly read/write access to the brain HTTP REST API.

Tests cover success, missing args, backend error propagation, and
empty stdin (which produces an empty content payload — the brain
server's responsibility to validate).
2026-05-03 21:42:36 +02:00
Mathias Bergqvist
cd5f3c0175 feat(hyperguild): brain query subcommand
Adds 'hyperguild brain query <topic>' against the brain HTTP REST
/query endpoint. Default human output prints path + score + title;
--json passes through the response envelope. --limit overrides the
default 5-result cap.

runBrainWrite remains a stub for Task 5.
2026-05-03 21:37:39 +02:00
Mathias Bergqvist
ed4966927c feat(hyperguild): brain HTTP REST client
Adds brainClient with Query and Write methods against the brain's
HTTP REST endpoints (/query, /write). Constructor reads BRAIN_URL env
var, defaulting to http://koala:30330 — the Tailscale-exposed
NodePort that serves both MCP and REST.

Tests cover success, transport error, and non-200 cases via
httptest fakes; URL override is verified via t.Setenv.
2026-05-03 21:32:48 +02:00
Mathias Bergqvist
3c4e8e8bb8 feat(hyperguild): tier subcommand
Adds the tier subcommand to the hyperguild CLI. Reuses
internal/tier.Detect verbatim, sources probe URLs from
ANTHROPIC_PROBE_URL (default https://api.anthropic.com) and
LITELLM_BASE_URL (no default — empty triggers airplane).

Human-readable output by default; --json emits the same Info struct
as the supervisor's tier MCP returns. Tests cover all three tier
states via httptest fakes.
2026-05-03 21:27:33 +02:00
Mathias Bergqvist
5c88eff46f feat(hyperguild): subcommand router skeleton
Lays down the cmd/hyperguild/ entry point. Defines the subcommand
contract (ctx, args, stdin, stdout, stderr) error, the dispatch()
function that's testable without os.Exit, and stubs for tier / brain /
mode that return errNotImplemented. Subsequent commits replace each
stub.

Part of Plan 4 (hyperguild CLI) of the hyperguild migration.
2026-05-03 21:21:08 +02:00
Mathias Bergqvist
646a86f2c3 docs(specs): fix brain URL port (3300 → 30330)
Pod-internal port is 3300; Tailscale-exposed NodePort is 30330.
External clients including the planned hyperguild CLI hit 30330.
2026-05-03 21:01:51 +02:00
Mathias Bergqvist
adf0504116 docs(specs): hyperguild CLI design — Plan 4 of hyperguild migration
Implementation-level spec for the hyperguild CLI: a stdlib Go binary
at cmd/hyperguild/ with subcommands tier, brain query/write, and mode
(cloud|client-local|sovereign). Replaces the supervisor's tier MCP and
provides shell-friendly access to the brain HTTP REST API.

Six measurable success criteria, seven out-of-scope items, six risks.
Decisions logged: stdlib flag + inline router (no cobra), reuse
internal/tier verbatim, BRAIN_URL env override, mode subcommand writes
.mcp.json with per-mode template plus placeholder for the Plan 6
routing pod.
2026-05-03 20:59:45 +02:00
Mathias Bergqvist
d44427e71f docs: document brain MCP endpoint at koala:30330
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 3s
- README architecture diagram now shows two MCP servers (supervisor +
  brain) with the brain hosted by ingestion directly.
- Connect-a-project example includes both servers.
- .context/PROJECT.md replaces the boilerplate "Knowledge base access"
  block with the actual hyperguild MCP endpoints.
- Adapters regenerated via task context:sync.

Captures the transitional state where two MCPs coexist; the supervisor
MCP will shrink as skill workers move to SKILL.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:03:45 +02:00
Mathias Bergqvist
2635cdcaa7 chore: add brain MCP server alongside supervisor
The brain MCP at koala:30330 hosts the brain_* and session_log tools
formerly on supervisor. Supervisor stays connected during the
transition; its skill workers and the brain duplication will be
removed in a later plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:02:28 +02:00
Mathias Bergqvist
e922471229 fix(context-sync): short-circuit when root AGENT.md is unreachable
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 3s
In CI's clean checkout the tree-walk for ~/dev/.context/AGENT.md
finds nothing, leaving ROOT_CONTEXT empty. The script previously
proceeded to regenerate AGENTS.md, .cursorrules,
.aider.conventions.md, and .context/system-prompt.txt as
project-only — but the committed versions are root+project, so
the drift gate added in cc401d9 fails CI on every push.

When no root context is reachable, only regenerate CLAUDE.md
(which is project-only by design — Claude Code walks up the tree
itself to find the root). The root-bearing adapters are left
untouched, eliminating the false-positive drift.

Local runs (with root context reachable) are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:55:43 +02:00
Mathias Bergqvist
87ff1f907c fix(ingestion): silence errcheck on resp.Body.Close in integration test
Some checks failed
CI / Lint / Test / Vet (push) Failing after 3s
CI / Mirror to GitHub (push) Has been skipped
CI's golangci-lint flagged the un-checked deferred Close. Match the
existing project pattern (defer func() { _ = ... }()).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:55:29 +02:00
Mathias Bergqvist
9cc179dec6 feat: brain MCP migration — extract brain_* + session_log into ingestion pod
Some checks failed
CI / Lint / Test / Vet (push) Failing after 2s
CI / Mirror to GitHub (push) Has been skipped
Slice 1 of the larger SKILL.md + routing-MCP architecture migration.
Adds an MCP HTTP handler to the ingestion service at POST /mcp,
exposing 5 tools (brain_query, brain_write, brain_ingest_raw,
brain_ingest, session_log). Plan and 9 implementation commits
preserved on the feat/brain-mcp-migration branch.

NodePort 30330 wired in infra repo separately (commit 008548e).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:05:08 +02:00
Mathias Bergqvist
370d30e376 feat(ingestion): mount MCP handler at POST /mcp
The ingestion server now exposes both REST and MCP on the same port
(3300). MCP shares brainDir, pipeline config, and LLM client with the
REST handlers — single source of process state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 19:41:05 +02:00
Mathias Bergqvist
bd0c1d75fd feat(ingestion): implement session_log MCP tool
Appends a JSON line to brainDir/sessions/<session_id>.jsonl using the
session package copied in Task 2. Required for upcoming pass-rate
logging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:04:40 +02:00
Mathias Bergqvist
8c87460bff feat(ingestion): implement brain_ingest MCP tool
Wraps pipeline.Run with the existing LLM client. Mirrors the HTTP
/ingest and /ingest-path semantics — accepts either path or
content+source, validates mutual exclusion, surfaces an explicit error
when the LLM client is not configured (test-mode).

ctx is threaded through to pipeline.Run for cancellation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:02:02 +02:00
Mathias Bergqvist
809d435480 feat(ingestion): implement brain_ingest_raw MCP tool
Wraps pipeline.RunRaw directly. Same dry-run semantics as the HTTP
/ingest-raw endpoint. Test exercises a single concept page; asserts
returned path and that no file is written under dry_run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:28:05 +02:00
Mathias Bergqvist
e4a94df4fc feat(ingestion): extract WriteNote helper and add brain_write MCP tool
api.WriteNote captures the file-write logic that was previously inline
in Handler.Write. The existing HTTP endpoint now delegates to it; the
new MCP brain_write tool reuses the same function. Path-traversal
guard is strengthened to explicitly reject filenames containing path
separators or "..", so the rejection is surfaced before filepath.Base
strips the suspicious component (the previous defense-in-depth prefix
check became unreachable for these inputs after Base normalisation).
HTTP error code for caller-input errors shifts from 500 to 400, which
is semantically correct and not exercised by any existing test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:25:38 +02:00
Mathias Bergqvist
7dcb5610fe feat(ingestion): implement brain_query MCP tool
Wraps the existing search.Query function. Same BM25 over
brain/knowledge/ and brain/wiki/ that the HTTP /query endpoint serves.
Plan note: handleCall switch replaces the single-line stub from Task 1
— no unknownToolError type to remove since Task 1 inlined the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:56:40 +02:00
Mathias Bergqvist
63c8d114e8 feat(ingestion): add session package for JSONL log persistence
Copy of internal/session from the supervisor module — the ingestion
service needs it for the upcoming session_log MCP tool. The supervisor
copy will be removed in the supervisor-retirement plan; until then
the two packages are intentionally identical and pinned (no edits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:54:24 +02:00
Mathias Bergqvist
54f7d373bd feat(ingestion): add MCP server skeleton with tools/list
Adds an MCP HTTP handler under ingestion/internal/mcp. Implements
initialize, tools/list, and the JSON-RPC notification skip from prior
work. Tool dispatch is stubbed (returns unknown-tool error) and will be
filled in by subsequent tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:43:23 +02:00
Mathias Bergqvist
a412eee427 docs: add brain MCP migration plan
13 TDD-disciplined tasks moving brain_* and session_log out of the
supervisor pod and into the ingestion pod's MCP handler. Slice 1 of
the larger SKILL.md + routing-MCP architecture migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:27:28 +02:00
Mathias Bergqvist
3d6f33881b chore: remove sources: from context:sync to disable Task source-cache
Some checks failed
CI / Lint / Test / Vet (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
Task's sources: declaration cached the regeneration on the assumption
that adapter outputs depend only on .context/AGENT.md (or PROJECT.md +
.skills/). The cache occasionally skipped legitimate runs after manual
edits to root or template content not detectable from those source paths.
context-sync.sh is idempotent and cheap; running it on every invocation
is the right default. The freshness gate (git status --porcelain) is
unaffected — it always checked the actual git tree state.
2026-04-30 23:14:20 +02:00
Mathias Bergqvist
07e3f341ef chore: re-sync context adapters after root prose cleanup
Some checks failed
CI / Lint / Test / Vet (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
Root AGENT.md dropped a stale paragraph; adapters that embed root
(AGENTS.md, .cursorrules, .aider.conventions.md, system-prompt.txt)
need to be regenerated to match. CLAUDE.md is project-only by design
and unchanged.
2026-04-30 23:00:31 +02:00
Mathias Bergqvist
5c532e708c chore: drop HANDROLLED sentinel machinery from context-sync.sh
Some checks failed
CI / Lint / Test / Vet (push) Failing after 2s
CI / Mirror to GitHub (push) Has been skipped
Nothing in the workspace uses the HANDROLLED escape hatch anymore — the
infra repo (its only consumer) was migrated to the standard pattern.
Removing the dead code: HANDROLLED_MARKER constant, skip_if_handrolled
function, and call sites in each generator. Sync output for any non-
HANDROLLED file (i.e., every file we have) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:48:28 +02:00
Mathias Bergqvist
a34c66d7cd fix: update scripts/context-sync.sh with HANDROLLED sentinel support
Some checks failed
CI / Lint / Test / Vet (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
Phase 4 sweep updated .gitignore and Taskfile.yml but missed this
script. Copy the canonical from ~/dev/project-template/ so the
HANDROLLED escape hatch works in this project (a CLAUDE.md or
adapter file containing '<!-- HANDROLLED: do not regenerate -->'
near the top is now skipped on regeneration).
2026-04-29 16:41:56 +02:00
Mathias Bergqvist
cc401d92d6 chore: commit adapters; add context freshness gate to task check
Adapters are now tracked so non-Mac hosts get full agent context after
a plain git pull. task check runs context:sync first and fails on drift
via git status --porcelain over the 5 adapter paths.
2026-04-29 15:59:52 +02:00
Mathias Bergqvist
9bdf00f51f refactor(mcp): connect Claude Code via direct HTTP, remove stdio bridge
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 3s
Claude Code now supports MCP servers over HTTP natively (type: "http"
in .mcp.json). The stdio↔HTTP bridge binary was a workaround for the
older stdio-only constraint and is no longer needed — the supervisor
NodePort on koala (30320) is reachable over Tailscale from any client
machine.

Removed:
- cmd/bridge/ (Go source, ~60 lines)
- bin/supervisor-bridge artifact
- Taskfile bridge:build target and the build aggregate's reference
- README "Build the bridge binary" instruction

Updated:
- .mcp.json switched to {type:"http", url:"http://koala:30320/mcp"}
- README architecture diagram and "Connect a project" section

Behavioural prerequisite for this change shipped in 7f7524c
(notifications fix). Verified end-to-end: tier tool call returns
{tier:2, label:"lan-only"} via direct HTTP, no shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:38:58 +02:00
Mathias Bergqvist
7f7524c859 fix(mcp): do not respond to JSON-RPC notifications
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
The supervisor's MCP HTTP handler was answering every parsed request,
including notifications (messages with no id field). Per JSON-RPC 2.0,
notifications must not receive a response. The Apr-29 incident saw
Claude Code's MCP client receive a -32601 error for the standard
notifications/initialized handshake step and disconnect immediately
after a successful initialize.

Skip writing the response when req.ID == nil. Cover both the
known-method (notifications/initialized) and unknown-method paths
with tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:39:20 +02:00
Mathias Bergqvist
0a70d9e972 feat(pipeline): add POST /ingest-raw for direct batch ingestion without LLM
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Has been skipped
Allows callers to provide pre-structured RawPage data directly, bypassing the
LLM extraction step. The pipeline still handles slug computation, frontmatter,
link canonicalization, source back-references, and dedup — only the extraction
is skipped. Useful when a more capable model or manual curation produces the
structured data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:15:59 +02:00
Mathias Bergqvist
3e9a648115 fix(pipeline): repair invalid JSON escape sequences from LLM output before parsing
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:04:27 +02:00
Mathias Bergqvist
923a665365 fix(pipeline): skip RawPages with empty title in BuildPages instead of producing broken paths
All checks were successful
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:55:37 +02:00
Mathias Bergqvist
537aebc302 feat(pipeline): update system prompt for new LLM JSON contract (no slugs)
- Change prompt to reflect new output format: title, type, subtype, domain, content
- Remove slug/path generation responsibility from LLM — pipeline now handles it
- Wikilinks change from [[slug|Display Name]] to [[Display Name]] only
- LLM no longer includes frontmatter or paths in output

docs(schema): update LLM output format and wikilink convention for Level 3

- Specify JSON schema: title, type, subtype, domain, content fields
- Remove frontmatter requirements from schema output (handled by pipeline)
- Simplify wikilink format to [[Display Name]] — no slug or pipe
- Pipeline now responsible for slug generation and frontmatter construction

These changes shift slug/frontmatter generation from LLM to pipeline,
reducing cognitive load on the model and improving control over output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:45:21 +02:00
Mathias Bergqvist
de35d4dbb0 feat(pipeline): wire ParseRawPages+BuildPages+CanonicalizeLinks into Run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:07:33 +02:00
Mathias Bergqvist
26855f69b0 feat(pipeline): add CanonicalizeLinks — convert [[Display Name]] to [[slug|Display Name]] 2026-04-23 18:59:10 +02:00
Mathias Bergqvist
a7b363d589 fix(pipeline): quote YAML scalar fields in buildFrontmatter to prevent injection 2026-04-23 18:56:39 +02:00
Mathias Bergqvist
7b57051af8 feat(pipeline): add BuildPages — compute slugs/paths/frontmatter from RawPage 2026-04-23 18:50:37 +02:00
Mathias Bergqvist
a620f6cb01 fix(pipeline): guard empty-title bridge + skip stale integration tests until task4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:46:07 +02:00
Mathias Bergqvist
26b5636b43 feat(pipeline): replace ParsePages with ParseRawPages + RawPage type
Strips slug authority from the LLM. The new RawPage type carries only
{title, type, subtype, domain, content} — no paths or frontmatter.
Pipeline will derive slugs deterministically (Task 4).

pipeline.go gets a temporary bridge stub (TODO task4) to keep the
package compiling between tasks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:41:33 +02:00
Mathias Bergqvist
989f375aec docs: add Level 3 implementation plan 2026-04-23 17:37:45 +02:00
Mathias Bergqvist
6403d5e444 docs: add Level 3 slug authority design spec 2026-04-23 17:23:22 +02:00
Mathias Bergqvist
ab19968ae2 feat: POST /backfill-refs — retroactive source back-reference injection
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Walks wiki/sources/, extracts wikilinks from each source page, and injects
## Sources back-refs into all linked concept and entity pages. All refs from
all sources are accumulated in memory before writing, so multiple sources
referencing the same concept are merged in a single write. Running the
endpoint multiple times is safe — wiki.Merge deduplicates bullet items.
2026-04-23 16:50:11 +02:00
Mathias Bergqvist
1605624668 feat(pipeline): add POST /backfill-refs endpoint to retroactively inject source back-references 2026-04-23 16:50:00 +02:00
Mathias Bergqvist
55fa0b503a feat: source back-references on concept and entity pages
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
After each ingestion, every concept and entity page linked from the
source page gains a ## Sources entry pointing back to that source.
Pages already on disk (from prior ingestions) are loaded and updated,
so re-ingesting a new source accumulates references over time.
Deduplication is handled by wiki.Merge's existing bullet-section logic.
2026-04-23 16:36:40 +02:00
Mathias Bergqvist
3c2bd9268c feat(pipeline): wire source back-reference injection into Run 2026-04-23 16:36:22 +02:00
Mathias Bergqvist
29727ec2a5 feat(pipeline): inject source back-references into concept and entity pages 2026-04-23 16:35:47 +02:00
Mathias Bergqvist
0a075088b2 docs: add source back-references implementation plan 2026-04-23 16:33:41 +02:00
Mathias Bergqvist
1bfe501d09 fix(cd): only deploy when CI passes on main
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-23 16:24:59 +02:00
Mathias Bergqvist
3607920601 fix(lint): resolve all errcheck violations in ingestion module
All checks were successful
cd / Build and deploy (push) Successful in 10s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-23 16:20:59 +02:00
Mathias Bergqvist
a6c39e8691 feat: PDF extraction and fuzzy entity resolution
Some checks failed
cd / Build and deploy (push) Successful in 11s
CI / Lint / Test / Vet (push) Failing after 5s
CI / Mirror to GitHub (push) Has been skipped
- New extract package: Text() dispatcher for .md/.txt passthrough and
  PDF extraction via pdftotext subprocess
- wiki.Entry gains Aliases []string, loaded from YAML frontmatter
- Fuzzy entity resolution in pipeline: normalizes titles (lowercase,
  strip articles, collapse hyphens) and matches proposed pages against
  existing inventory slugs and aliases to prevent proliferation
- Watcher and API handler now use extract.Text() instead of os.ReadFile
- Dockerfile: apk add poppler-utils in Alpine runtime stage
2026-04-23 16:03:02 +02:00
Mathias Bergqvist
a37d18bf7a chore(docker): add poppler-utils for PDF text extraction 2026-04-23 16:02:12 +02:00
Mathias Bergqvist
2975eadc87 feat(watcher,api): use extract.Text() for file reading — fixes PDF ingestion 2026-04-23 16:01:36 +02:00
Mathias Bergqvist
53e46781b1 feat(pipeline): resolve proposed pages against inventory before writing 2026-04-23 16:00:31 +02:00
Mathias Bergqvist
e9b5cc401c feat(pipeline): add fuzzy entity resolution to prevent slug proliferation 2026-04-23 15:59:36 +02:00
Mathias Bergqvist
bf6f497d9d feat(wiki): add Aliases to Entry and read from YAML frontmatter 2026-04-23 15:57:16 +02:00
Mathias Bergqvist
9cc6c2d053 feat(extract): implement PDF extraction via pdftotext 2026-04-23 15:53:46 +02:00
Mathias Bergqvist
43a46d07e5 feat(extract): add Text() dispatcher with md/txt passthrough 2026-04-23 15:45:20 +02:00
Mathias Bergqvist
820d1c93a7 docs: add implementation plan for PDF extraction and entity resolution 2026-04-23 15:44:13 +02:00
Mathias Bergqvist
6928907d79 fix(watcher): copy files instead of moving them, leave originals for Obsidian
Some checks failed
cd / Build and deploy (push) Successful in 10s
CI / Lint / Test / Vet (push) Failing after 5s
CI / Mirror to GitHub (push) Has been skipped
Files dropped into brain/raw/ are now copied to processed/ or failed/ rather
than moved. A .processed or .failed marker is written next to the original so
the watcher skips it on subsequent polls without deleting it. This keeps
Syncthing-synced Obsidian vaults intact after ingestion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:47:50 +02:00
Mathias Bergqvist
e74320a8e8 feat(ingestion): wire watcher into server startup + fix Procfile env vars
Some checks failed
cd / Build and deploy (push) Successful in 10s
CI / Lint / Test / Vet (push) Failing after 5s
CI / Mirror to GitHub (push) Has been skipped
- Start background watcher on startup when INGEST_WATCH_INTERVAL > 0
- Procfile: add INGEST_WATCH_INTERVAL=30 and INGEST_SVC_URL for supervisor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:09:00 +02:00
Mathias Bergqvist
1b0706f270 chore(brain): rename CLAUDE.md to schema.md for clarity
CLAUDE.md has a specific meaning in the Claude Code ecosystem (agent
instructions). The wiki schema for the ingestion pipeline should live
in schema.md to avoid confusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:06:32 +02:00
Mathias Bergqvist
2ae6bfe81e fix(brain): enforce mutual exclusivity and clarify brain_ingest schema
- Return error when both path and content are supplied simultaneously
- Improve tool description to clearly state the two valid call forms
- Add per-field descriptions so LLMs understand what each parameter requires

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:03:03 +02:00
Mathias Bergqvist
a6dce972d6 feat(brain): add path field to brain_ingest for /ingest-path routing
Adds an optional path field to brain_ingest so Claude can ingest files
or directories directly by path without embedding content in the call.
Routing: path set → /ingest-path; content+source set → /ingest; neither → error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:01:05 +02:00
Mathias Bergqvist
2f4b577131 fix(ingestion): address code review issues in api and watcher packages
- Strip internal error detail from 500 responses (leak prevention)
- Add path containment assertion in /write handler
- Use Go 1.22 method-prefixed mux routes for automatic 405 responses
- Clarify watch_interval log when watcher not yet wired
- Consolidate validation tests into table-driven TestIngest_Validation
- Watcher: return nil after successful quarantine to avoid double-logging
- Watcher: append timestamp suffix to processed dest if file already exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:59:39 +02:00
Mathias Bergqvist
a25bb18c54 feat(ingestion): add /ingest and /ingest-path HTTP handlers
Wires pipeline.Run into the HTTP layer so callers can ingest raw text
or files/directories without touching the filesystem directly. Rewrites
main.go to parse LLM and watcher env vars and build pipeline.Config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:54:28 +02:00
Mathias Bergqvist
78531bb238 feat(ingestion): add background file watcher for brain/raw/
Polls brain/raw/ on a configurable ticker, derives human-readable source
names from filenames, runs the pipeline, and moves files to
processed/YYYY-MM-DD/ on success or failed/ on error with a log.md entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:54:03 +02:00
Mathias Bergqvist
04fefe8e9c fix(ingestion): wrap naked error returns and harden mustJSON helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:51:19 +02:00
Mathias Bergqvist
103f4d90bf feat(ingestion): add pipeline orchestrator with prompt builder
Adds prompt.go (BuildPrompt + systemPrompt) and pipeline.go (Run, Config,
Result, mergeAll) that wire chunking, LLM calls, parse, merge, index rebuild,
and log append into a single ingestion pipeline. Includes integration tests
covering write, dry-run, and duplicate-path merge scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:45:19 +02:00
Mathias Bergqvist
9b11719481 feat(ingestion): add content chunking and LLM JSON output parser 2026-04-22 22:37:14 +02:00
Mathias Bergqvist
d405346f07 feat(ingestion): add wiki index rebuilder and audit log 2026-04-22 22:36:55 +02:00
Mathias Bergqvist
bf8a3fc11c feat(ingestion): add OpenAI-compatible LLM HTTP client with 429 retry 2026-04-22 22:29:24 +02:00
Mathias Bergqvist
ae5a4d04f0 feat(ingestion): add wiki page merge logic 2026-04-22 22:28:55 +02:00
Mathias Bergqvist
3a0424a6b4 feat(ingestion): add wiki inventory loader 2026-04-22 22:28:53 +02:00
Mathias Bergqvist
08dd7b9365 docs(brain): add wiki schema document for ingest prompt 2026-04-22 22:25:52 +02:00
Mathias Bergqvist
91e02b930c feat(ingestion): add wiki package with Page types and slug generation 2026-04-22 22:25:45 +02:00
Mathias Bergqvist
c7341a2607 feat(config): add IngestSvcURL and KBRetrievalURL to supervisor config 2026-04-22 22:24:27 +02:00
Mathias Bergqvist
b5a0085c0a feat(brain): add brain_ingest, brain_search tools and extend search to wiki/ 2026-04-22 22:16:02 +02:00
Mathias Bergqvist
d6daa37c71 docs: add brain ingestion pipeline implementation plan 2026-04-22 22:14:59 +02:00
Mathias Bergqvist
62fc3989f2 docs: add brain ingestion pipeline design spec 2026-04-22 22:05:19 +02:00
Mathias Bergqvist
c9310b1079 fix(ingestion): always append .md extension to written filenames
All checks were successful
cd / Build and deploy (push) Successful in 9s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 4s
brain_write with a custom filename omitted the .md extension, causing
search to skip the file (search.go filters on HasSuffix .md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 19:23:07 +02:00
Mathias Bergqvist
ca8a691241 fix(exec): strip trailing result-schema JSON from local model output
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Small models (phi4-mini) produce correct markdown analysis but then
append the old {status/phase/skill} JSON schema out of training habit.
stripResultJSON() detects and removes these trailing fences so Claude
Code receives clean prose regardless of model behaviour.

Non-schema json blocks (config examples etc) are preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:55:53 +02:00
Mathias Bergqvist
214f607007 fix(config): make no-JSON instruction unmissable in protocols.md
All checks were successful
cd / Build and deploy (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Local models (phi4-mini, qwen3-coder-30b) ignore soft instructions
and revert to JSON from their training. Move the prohibition to the
very top with bold caps before any other content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:51:51 +02:00
Mathias Bergqvist
0e08dfffb8 fix(config): rewrite all skill discipline files for simplified model
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Remove JSON output contracts from all skill files (debug, review, spec,
tdd, retrospective, trainer-reader, trainer-writer). Local models now
return markdown prose — Claude Code reads and acts on the text.

Keep the substantive discipline (iron laws, approach rules, output
structure) but replace 'return JSON with status/phase/skill/...' with
clear markdown format instructions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:46:52 +02:00
Mathias Bergqvist
caef05bea4 fix(config): rewrite protocols.md for simplified model
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 3s
Remove JSON output contract, verification rules, escalation, and scope
limits that applied to the old Claude subprocess workers. Local models
are now consultants returning markdown prose, not JSON executors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:43:04 +02:00
Mathias Bergqvist
ca1a16873c feat(ingestion): add Dockerfile and extend CD to build+push ingestion image
All checks were successful
cd / Build and deploy (push) Successful in 9s
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 3s
Ingestion server is a pure-Go HTTP binary — alpine runtime, no node.js.
CD now builds both supervisor and ingestion images on every push,
updates both deployment.yaml files in the infra repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:37:11 +02:00
Mathias Bergqvist
63c238c650 fix(config): update model names to match LiteLLM host/name format
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 9s
CI / Mirror to GitHub (push) Successful in 4s
Replace ollama/ prefix with iguana/ and koala/ prefixes to match
actual model IDs exposed by LiteLLM on this cluster.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:34:53 +02:00
Mathias Bergqvist
ce45592730 refactor: replace orchestrator/verifier chain with direct LiteLLM calls
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
Drop the three-layer Claude subprocess orchestration (local model →
Claude verifier → cloud escalation). Skills now call LiteLLM directly
and return plain text to Claude Code, which decides what to do with it.

- Delete executor, orchestrator, verifier, result, attempts packages
- Simplify LiteLLMExecutor: Run(Request)→Result becomes Complete(model,sys,user)→(string,int64,error)
- Replace ExecutorFn with CompleteFunc in all 6 skill configs
- Rewrite all skill handlers to call Complete and return {"text","model","duration_ms"}
- Simplify config/models: remove Verifier/LlamaSwapURL, add ModelFor
- Bump version to v0.5.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:19:09 +02:00
Mathias Bergqvist
823de23213 feat(exec): log per-attempt chain verdicts for pass rate visibility
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 4s
2026-04-22 15:40:15 +02:00
Mathias Bergqvist
78d3939caa feat(config): wire protocols.md into every worker as shared behavioral contract 2026-04-22 15:39:25 +02:00
Mathias Bergqvist
f2bc39b500 feat(skills): inject brain context into review, debug, spec, tdd before spawning workers 2026-04-22 15:37:56 +02:00
Mathias Bergqvist
3625e1268d feat(ingestion): simplify brain to knowledge/ — write and search use same dir 2026-04-22 15:36:10 +02:00
Mathias Bergqvist
47df642836 feat(brain): add Query client for skill handler context injection 2026-04-22 15:34:09 +02:00
Mathias Bergqvist
235d70ad0b docs: document hyperguild scope reset — drop parametric learning, simplify brain 2026-04-22 15:27:52 +02:00
Mathias Bergqvist
7d5289ac54 chore: bump version to v0.4.0
All checks were successful
cd / Build and deploy (push) Successful in 6s
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-22 13:38:23 +02:00
Mathias Bergqvist
3d8fc9dacd feat(skills): wire session.Append into retrospective and trainer 2026-04-22 13:37:43 +02:00
Mathias Bergqvist
f9f804cd49 feat(skills): wire session.Append and PrependHistory into tdd 2026-04-22 13:37:06 +02:00
Mathias Bergqvist
85f142ade0 feat(skills): wire session.Append and PrependHistory into spec 2026-04-22 13:36:35 +02:00
Mathias Bergqvist
0dfad02513 feat(skills): wire session.Append and PrependHistory into review and debug 2026-04-22 13:36:13 +02:00
Mathias Bergqvist
c44eb680b2 feat(exec): surface AttemptRecord slice on Result for session logging 2026-04-22 13:35:38 +02:00
Mathias Bergqvist
38ada998a2 feat(session): add AttemptsFrom converter for exec.AttemptRecord 2026-04-22 13:35:09 +02:00
Mathias Bergqvist
74547c2bdf feat(session): export PrependHistory for shared use across skills 2026-04-22 13:34:48 +02:00
Mathias Bergqvist
587c0d3b1c chore: bump startup log to v0.3.1 — CD pipeline smoke test
All checks were successful
cd / Build and deploy (push) Successful in 33s
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-22 12:18:27 +02:00
Mathias Bergqvist
bb61f2992b fix(cd): connect to Gitea SSH via localhost:30022 NodePort
All checks were successful
cd / Build and deploy (push) Successful in 5s
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Successful in 3s
gitea.d-ma.be:30022 is refused externally — the NodePort is only
reachable on koala locally. Use HostName 127.0.0.1 in SSH config
so git@gitea.d-ma.be connects to localhost:30022 instead.
2026-04-21 19:43:06 +02:00
Mathias Bergqvist
3ba72d9b28 fix(cd): replace heredoc with printf to avoid YAML parse error
Some checks failed
cd / Build and deploy (push) Failing after 5s
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
Unindented heredoc content inside a YAML literal block breaks parsing.
Gitea silently drops workflows with YAML errors, causing the CD job
to never trigger.
2026-04-21 19:41:09 +02:00
Mathias Bergqvist
b4f0fbc3ea chore: retrigger CD with SSH port fix
All checks were successful
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-21 19:35:30 +02:00
Mathias Bergqvist
12943ee6f4 fix(cd): use NodePort 30022 for Gitea SSH in infra repo update
All checks were successful
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
gitea.d-ma.be port 22 is rejected (NPM only proxies HTTP/HTTPS).
The runner runs on koala where the Gitea SSH NodePort 30022 is
reachable locally. Use SSH config override instead of ssh-keyscan.
2026-04-21 19:28:28 +02:00
Mathias Bergqvist
9af95ebd96 chore: retrigger CD after NPM body size fix
Some checks failed
cd / Build and deploy (push) Failing after 14s
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-21 17:56:47 +02:00
Mathias Bergqvist
f0b567f3e6 chore: retrigger CD after ingress body size fix
Some checks failed
cd / Build and deploy (push) Failing after 6s
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-21 17:43:54 +02:00
Mathias Bergqvist
e3d6cf4cf5 chore: retrigger CD after buildkit dir permissions fix
Some checks failed
cd / Build and deploy (push) Failing after 26s
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-21 17:28:42 +02:00
Mathias Bergqvist
df59bd010c chore: retrigger CD after act_runner restart
Some checks failed
cd / Build and deploy (push) Failing after 1s
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-21 12:42:56 +02:00
Mathias Bergqvist
e5152151d6 chore: retrigger CD after buildkit group fix
Some checks failed
cd / Build and deploy (push) Failing after 0s
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-21 12:18:25 +02:00
Mathias Bergqvist
aa2d57e619 chore: retrigger CD pipeline
Some checks failed
cd / Build and deploy (push) Failing after 1s
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-21 12:02:30 +02:00
Mathias Bergqvist
6b53706987 fix(cd): remove cross-workflow needs dependency
Some checks failed
cd / Build and deploy (push) Failing after 1s
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Successful in 3s
needs: [check] only works within the same workflow file; the check job
lives in ci.yml, causing the deploy job to queue indefinitely.
2026-04-21 11:48:56 +02:00
Mathias Bergqvist
a0cfc866df feat: add CD pipeline (Dockerfile, .dockerignore, cd.yml)
Some checks failed
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
cd / Build and deploy (push) Has been cancelled
BuildKit → OCI tar → skopeo push to Gitea registry → infra repo
image tag patch → Flux reconciles new pod on koala.
2026-04-21 10:49:38 +02:00
Mathias Bergqvist
7bf19b6a7b fix: replace buildctl push with skopeo for simpler registry auth 2026-04-21 07:05:44 +02:00
Mathias Bergqvist
19b019a8d8 fix: ensure SSH key cleanup on failure in CD workflow 2026-04-20 21:38:11 +02:00
Mathias Bergqvist
4ef6a22e28 feat: add CD workflow (buildctl → Gitea registry → infra repo update) 2026-04-20 21:36:22 +02:00
Mathias Bergqvist
3796cfca87 fix: add .dockerignore and non-root USER to Dockerfile 2026-04-20 20:27:42 +02:00
Mathias Bergqvist
7ce544a051 feat: add multi-stage Dockerfile with claude CLI runtime 2026-04-20 20:24:20 +02:00
Mathias Bergqvist
391720155e docs: add CD pipeline implementation plan 2026-04-20 20:17:07 +02:00
Mathias Bergqvist
ae6600b8d2 docs: add CD pipeline design spec (BuildKit + Flux GitOps) 2026-04-20 20:10:16 +02:00
Mathias Bergqvist
6328766c7f fix(main): re-evaluate chain per call to respect caller model override
All checks were successful
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Has been skipped
buildOrch now returns a closure instead of *Orchestrator. Each invocation
calls models.ChainFor(skill, req.Model) so a non-empty caller override
collapses to a single-entry chain (no escalation). The attempts slice is
also allocated fresh per call, preventing unbounded growth across requests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:34:09 +02:00
Mathias Bergqvist
f1deedd39d feat(main): wire per-skill Orchestrators replacing single executor.Run
Each skill now gets its own Orchestrator built from its ChainFor entry,
with LiteLLM for local tiers and Claude for cloud tiers. Removes the
defunct models.Resolve calls and single shared executor.Run pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:28:10 +02:00
Mathias Bergqvist
5cb272a869 feat(exec): add Orchestrator chain walker with verification and warm-state logging 2026-04-20 11:06:13 +02:00
Mathias Bergqvist
e96b39a812 feat(exec): add Claude verifier for local model output quality gate 2026-04-20 11:02:59 +02:00
Mathias Bergqvist
5db5b33cd7 feat(exec): add LiteLLM HTTP executor for local model dispatch 2026-04-20 10:46:08 +02:00
Mathias Bergqvist
a32457b5bc feat(exec): pass --model flag to claude subprocess for cloud-tier dispatch 2026-04-20 08:55:03 +02:00
Mathias Bergqvist
e0be5f0f98 feat(config): replace single-model config with chain-based routing
Implements escalation chains per skill with three-layer priority:
1. Caller override (model param) — no escalation
2. Per-skill chain from models.yaml
3. default_chain fallback

New APIs:
- Verifier() — fixed verifier for output validation
- LlamaSwapURL() — base URL for warm-state probing
- ChainFor(skill, override) — ordered model list for escalation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 08:48:33 +02:00
Mathias Bergqvist
6d410b810b feat(session): extend Attempt with tier, timing, and verdict fields 2026-04-20 08:35:27 +02:00
Mathias Bergqvist
76f195de2a docs: model orchestration design spec for Phase 3
All checks were successful
CI / Lint / Test / Vet (push) Successful in 1m8s
CI / Mirror to GitHub (push) Successful in 4s
2026-04-20 07:45:32 +02:00
Mathias Bergqvist
f901d4e67d fix(ci): use --follow-tags instead of --tags to avoid re-pushing existing tags
All checks were successful
CI / Lint / Test / Vet (push) Successful in 1m9s
CI / Mirror to GitHub (push) Successful in 3s
2026-04-19 19:16:22 +02:00
Mathias Bergqvist
509c04b6e4 fix(session): use fmt.Fprintf with nolint to satisfy both staticcheck and errcheck
Some checks failed
CI / Lint / Test / Vet (push) Successful in 1m7s
CI / Mirror to GitHub (push) Failing after 3s
2026-04-19 18:56:12 +02:00
Mathias Bergqvist
738275252c feat: hyperguild phase 2 — review/debug/spec/trainer skills with session history injection
Some checks failed
CI / Lint / Test / Vet (push) Failing after 3s
CI / Mirror to GitHub (push) Has been skipped
2026-04-19 14:38:05 +02:00
Mathias Bergqvist
38fcac4cba feat(trainer): add trainer MCP skill with reader→writer sub-agent chain
Reader agent scans session logs for SFT/DPO candidates; writer receives
reader output and formats+writes training pairs to brain/training-data/.
Adds trainer-reader.md and trainer-writer.md discipline prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 14:06:00 +02:00
Mathias Bergqvist
7697e901d2 feat(spec): add spec writing MCP skill
Adds the spec skill that generates structured implementation specs from
requirements and writes them to a configurable output path in the project.
Follows the same pattern as review/debug skills with session history injection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 11:59:28 +02:00
Mathias Bergqvist
8cff57009a feat(debug): add debug MCP skill with hypothesis generation
Implements the debug skill following the same pattern as review. The skill
accepts project_root + error (+ optional context/model/session_id), prepends
session history, and calls the executor to produce 3-5 ordered hypotheses —
diagnosis only, no fixes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 11:29:58 +02:00
Mathias Bergqvist
8fb44affef feat(review): add code review MCP skill with session history injection
Implements the review skill following the same pattern as retrospective/tdd.
Validates project_root and files args, prepends session history when a
session_id is provided, and delegates to the executor with Read,Bash tools.
Iron-law discipline prompt enforces CRITICAL/WARNING/SUGGESTION output format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 11:11:29 +02:00
Mathias Bergqvist
582ca5019b feat(tdd): inject session history into green and refactor worker prompts
Adds SessionsDir to tdd.Config, session_id to tool input schemas, and a
prependHistory method that reads the session JSONL log and prepends a
formatted history block to the task prompt before worker invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 10:18:23 +02:00
Mathias Bergqvist
858a9ba1a1 fix(exec): expand validPhases and remove schema enum constraint for phase 2026-04-19 10:03:21 +02:00
Mathias Bergqvist
cbef2da8de feat(session): add FormatHistory for worker context injection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 09:40:41 +02:00
227 changed files with 41186 additions and 1038 deletions

315
.aider.conventions.md Normal file
View File

@@ -0,0 +1,315 @@
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: supervisor
- **Owner**: Mathias
- **Client**: personal
- **Repo**:
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## MCP endpoints
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
(opt-in). Only `mode client-local` registers this endpoint.
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM

View File

@@ -45,13 +45,30 @@
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## Knowledge base access
## MCP endpoints
This project can query the shared knowledge base via MCP or HTTP:
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
- **MCP endpoint**: `mcp://localhost:3100/knowledge`
- **HTTP fallback**: `http://localhost:3100/api/v1/search`
- **Scoping**: queries are filtered to collection `personal` + `public`
- **`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

322
.context/system-prompt.txt Normal file
View File

@@ -0,0 +1,322 @@
You are a coding assistant working on a specific project.
Follow all conventions from both the root agent context and project context.
---
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: supervisor
- **Owner**: Mathias
- **Client**: personal
- **Repo**:
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## MCP endpoints
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
(opt-in). Only `mode client-local` registers this endpoint.
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM
---

318
.cursorrules Normal file
View File

@@ -0,0 +1,318 @@
# Cursor rules — auto-generated
# Do not edit. Run: task context:sync
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild` → `knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: supervisor
- **Owner**: Mathias
- **Client**: personal
- **Repo**:
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## MCP endpoints
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
(opt-in). Only `mode client-local` registers this endpoint.
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitea
.worktrees
.DS_Store
*.log
.env*
.vscode
.idea
bin/
brain/

166
.gitea/workflows/cd.yml Normal file
View File

@@ -0,0 +1,166 @@
name: cd
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
jobs:
deploy:
name: Build and deploy
runs-on: self-hosted
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }}
environment: staging
env:
INGESTION_IMAGE: gitea.d-ma.be/mathias/ingestion
ROUTING_IMAGE: gitea.d-ma.be/mathias/routing
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push ingestion image
run: |
set -e
trap 'rm -f /tmp/ingestion-image.tar' EXIT
IMAGE_TAG="${{ github.sha }}"
echo "Building ${INGESTION_IMAGE}:${IMAGE_TAG}"
buildctl --addr "${BUILDKIT_HOST}" build \
--frontend dockerfile.v0 \
--local context=ingestion \
--local dockerfile=ingestion \
--output type=oci,dest=/tmp/ingestion-image.tar
skopeo copy \
oci-archive:/tmp/ingestion-image.tar \
docker://${INGESTION_IMAGE}:${IMAGE_TAG} \
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
echo "Built and pushed ${INGESTION_IMAGE}:${IMAGE_TAG}"
- name: Build and push routing image
run: |
set -e
trap 'rm -f /tmp/routing-image.tar' EXIT
IMAGE_TAG="${{ github.sha }}"
echo "Building ${ROUTING_IMAGE}:${IMAGE_TAG}"
buildctl --addr "${BUILDKIT_HOST}" build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--opt filename=Dockerfile.routing \
--opt build-arg:VERSION="${IMAGE_TAG}" \
--output type=oci,dest=/tmp/routing-image.tar
skopeo copy \
oci-archive:/tmp/routing-image.tar \
docker://${ROUTING_IMAGE}:${IMAGE_TAG} \
--dest-creds "${{ secrets.REGISTRY_CREDS }}"
echo "Built and pushed ${ROUTING_IMAGE}:${IMAGE_TAG}"
- name: Update infra repo
run: |
set -e
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
IMAGE_TAG="${{ github.sha }}"
mkdir -p ~/.ssh
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
chmod 600 ~/.ssh/infra_deploy_key
printf 'Host gitea.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
git clone "${INFRA_REPO}" /tmp/infra-update
cd /tmp/infra-update
sed -i "s|gitea.d-ma.be/mathias/ingestion:.*|gitea.d-ma.be/mathias/ingestion:${IMAGE_TAG}|" \
"k3s/apps/supervisor/ingestion-deployment.yaml"
sed -i "s|gitea.d-ma.be/mathias/routing:.*|gitea.d-ma.be/mathias/routing:${IMAGE_TAG}|" \
"k3s/apps/routing/deployment.yaml"
git config user.email "cd-bot@d-ma.be"
git config user.name "CD Bot"
git add "k3s/apps/supervisor/ingestion-deployment.yaml" \
"k3s/apps/routing/deployment.yaml"
git commit -m "chore(deploy): ingestion+routing → ${IMAGE_TAG}"
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
git push
echo "Infra repo updated: ingestion+routing → ${IMAGE_TAG}"
- name: Trigger Flux reconcile (immediate)
run: |
kubectl -n flux-system annotate gitrepository flux-system \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
kubectl -n flux-system annotate kustomization apps \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
- name: Wait for Flux to apply new ingestion image
run: |
EXPECTED="gitea.d-ma.be/mathias/ingestion:${{ github.sha }}"
for i in $(seq 1 60); do
CURRENT=$(kubectl get deploy ingestion -n supervisor \
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
if [ "$CURRENT" = "$EXPECTED" ]; then
echo "✓ Flux applied ingestion image after ${i}s"
break
fi
sleep 1
done
kubectl get deploy ingestion -n supervisor \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
| grep -qx "$EXPECTED" \
|| { echo "✗ Flux did not apply ingestion image within 60s"; exit 1; }
- name: Verify ingestion rollout
run: |
kubectl rollout status deployment/ingestion \
--namespace supervisor \
--timeout=120s \
|| {
echo "── pod status ──"
kubectl get pods -n supervisor -o wide
echo "── events ──"
kubectl get events -n supervisor --sort-by='.lastTimestamp' | tail -20
echo "── describe ──"
kubectl describe pods -n supervisor -l app=ingestion | tail -40
exit 1
}
- name: Wait for Flux to apply new routing image
run: |
EXPECTED="gitea.d-ma.be/mathias/routing:${{ github.sha }}"
for i in $(seq 1 60); do
CURRENT=$(kubectl get deploy routing -n routing \
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
if [ "$CURRENT" = "$EXPECTED" ]; then
echo "✓ Flux applied routing image after ${i}s"
break
fi
sleep 1
done
kubectl get deploy routing -n routing \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
| grep -qx "$EXPECTED" \
|| { echo "✗ Flux did not apply routing image within 60s"; exit 1; }
- name: Verify routing rollout
run: |
kubectl rollout status deployment/routing \
--namespace routing \
--timeout=120s \
|| {
echo "── pod status ──"
kubectl get pods -n routing -o wide
echo "── events ──"
kubectl get events -n routing --sort-by='.lastTimestamp' | tail -20
echo "── describe ──"
kubectl describe pods -n routing -l app=routing | tail -40
exit 1
}

View File

@@ -53,6 +53,6 @@ jobs:
chmod 600 ~/.ssh/id_rsa_gh_mirror
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_rsa_gh_mirror -o IdentitiesOnly=yes" \
git push git@github.com:mathiasb/hyperguild.git HEAD:main --tags
git push git@github.com:mathiasb/hyperguild.git HEAD:main --follow-tags
rm ~/.ssh/id_rsa_gh_mirror
echo "✓ Mirrored to GitHub"

9
.gitignore vendored
View File

@@ -13,15 +13,7 @@ brain/training-data/**/*.jsonl
# Go
vendor/
# ── Generated context files (adapter outputs) ──
# Canonical sources: .context/PROJECT.md + .skills/*/SKILL.md
# Everything below is disposable — regenerate with: task context:sync
AGENTS.md
CLAUDE.md
.cursorrules
.aider.conventions.md
.aider.conf.yml
.context/system-prompt.txt
# ── Sensitive ──
.env
@@ -34,6 +26,7 @@ secrets/
# ── Documented examples (commit these) ──
!.env.example
!config/supervisor/CLAUDE.md
!brain/CLAUDE.md
# IDE
.idea/

11
.mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"brain": {
"type": "http",
"url": "https://brain-mcp.d-ma.be/mcp",
"headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
}
}
}
}

315
AGENTS.md Normal file
View File

@@ -0,0 +1,315 @@
# Agent context — Mathias workspace
<!-- Canonical root context for all AI coding agents.
Lives at: ~/dev/.context/AGENT.md
Applies to every project under ~/dev/ unless overridden.
Run `task context:sync` from ~/dev/ to regenerate harness-specific files.
Project-level context in .context/PROJECT.md layers on top of this. -->
## Who I am
I'm Mathias, a digital product manager and technology consultant based in Sweden.
I build software, research emerging tech, and deliver consulting engagements
for clients under NDA. I work across AI/ML, financial automation, web applications,
and climate/sustainability tech.
## How I work with agents
- I think like a product manager — I care about *why* before *how*
- I want agents to be opinionated and push back, not just execute blindly
- I prefer concise responses; skip ceremony and get to the point
- When I say "build this", I mean production-quality with tests, not a demo
- Ask me before making irreversible changes or adding heavy dependencies
- I work with confidential client data — never send it to cloud APIs unless I explicitly say it's OK
## Behavior rules
These rules apply to every task across every project, regardless of harness.
1. **No assumptions.** Don't hide confusion — surface it. Surface tradeoffs explicitly.
Think before coding; if the problem is unclear, ask or state assumptions before acting.
2. **Minimum viable code.** Solve with the smallest change that works. Nothing
speculative, no "while we're here" cleanups, no premature abstractions. Simplicity first.
3. **Surgical changes.** Touch only what the task requires. Leave unrelated code,
files, and formatting alone. Diffs should be small and reviewable.
4. **Goal-driven execution.** Define clear success criteria up front for every task.
Loop — implement, verify, refine — until those criteria are met. Don't claim
completion without evidence (tests pass, command output, observed behavior).
5. **Trunk-Based Development — commit directly to main.** Every commit is one
logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be
actively working on the same repo simultaneously, create a short-lived branch
(`agent/<description>`), finish the task, and merge to main within the same
session. Do not leave agent branches open between sessions.
**Exception — external contributor or client four-eyes requirement:** Use
PR flow only when a human reviewer outside the project is required. Document
the reason in PROJECT.md.
## Default stack
| Layer | Default | Fallback | Last resort |
|-------|---------|----------|-------------|
| Language | Go | Python | TypeScript, Java, C |
| UI | HTMX + Templ | Server-rendered HTML | React (only if SPA is justified) |
| Build | Task (taskfile.dev) | Make | — |
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | — | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
one logical change per commit, CI is the quality gate
- **Never**: long-lived feature branches, PRs for solo work, direct push without
passing `task check` locally first
- **Security**: no secrets in code, govulncheck before adding deps, SOPS for encrypted config
- **Dependencies**: prefer stdlib. testify, slog, templ, sqlc, google.golang.org/adk (agent projects only) are pre-approved; anything else needs justification in the commit message
## Infrastructure
Three machines on Tailscale:
| Machine | Role | Key specs |
|---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines
- **Networking**: Tailscale mesh
## Project landscape
All development repos live at `~/dev/` (softlink from `~/Documents/local-dev/`).
Organized in thematic folders:
| Folder | Focus | Count |
|--------|-------|-------|
| `GO/` | Go web frameworks, API integrations, learning projects | ~10 |
| `AI/` | ML research, AI frameworks (FinRL, DSPy, crawl4ai) | ~6 |
| `AGENTS/` | Autonomous agents, coding agents, MCP servers, infra | ~15 |
| `QKX/` | Invoice processing, financial automation, payment systems | ~13 |
| `XT/` | Climate data, sustainability (Klimatkollen, Garbo) | ~2 |
See `~/dev/PROJECT_SUMMARY.md` for detailed descriptions of each project.
### Key active projects
- **super-koala** (`AGENTS/`) — multi-component agent stack with LangGraph, DSPy, MCP
- **azure-tiger** (`QKX/`) — invoice extraction → ISO 20022 payment instructions
- **gocrwl** (`AGENTS/`) — Go web crawler with containerized deployment
- **koala-ai-stack** (`AGENTS/`) — local AI server infrastructure management
- **klimatkollen** (`XT/`) — Swedish municipal climate data platform
## Knowledge base — actively use it
A persistent brain (BM25 search + LLM-synthesised Q&A) survives across sessions,
hosts, and harnesses. It holds 100+ hard-won entries: infra incident postmortems,
Go pitfalls, framework gotchas, design principles, ADRs. **It is not optional
reference material — query it actively, not just when explicitly told.**
### When to query (treat as a reflex)
- **Before** starting a non-trivial task — search for prior art with the symptom
AND the system component ("how did we solve X in Y?"). 5 seconds beats 5 hours.
- **When debugging** — search for the error string, the stack frame, the affected
service. Past you may have already paid this tax.
- **Before adopting** a pattern, library, framework, or model name — check if it
was tried and rejected, or what the integration footguns are.
- **When making architectural decisions** — search for the domain + "ADR" or
"decision" to find prior reasoning before re-deriving it.
- **When a recommendation feels novel** — challenge yourself: "has this been
documented?" The brain often has it.
### When to write
After you discover something that **future-you would forget** and that **isn't
recoverable from the code, git log, or PR description alone**:
- Bugs whose root cause is non-obvious and generalisable beyond this project.
- Framework / library / model-name quirks that bit you and would bite anyone.
- Design principles validated under fire (e.g. "every `_get` needs a `_list`").
- Postmortems for incidents: what broke, why, how diagnosed, what to do next time.
DON'T write project status, sprint progress, PR summaries, or "what I did this
session" — those rot fast and the originals are in git/gitea anyway. Brain
entries that age well are about *why*, *how to avoid*, and *what to do when*.
### How to access (per harness)
| Harness | Query | Write |
|---------|-------|-------|
| **Claude Code, Claude Desktop** | `brain_query` (BM25), `brain_answer` (LLM-synth + sources) MCP tools | `brain_write` MCP tool |
| **Crush, Pi, Antigravity, other MCP-capable** | same MCP server: `ingestion-brain` (via the `mcp__*_brain__*` namespace once authenticated) | same |
| **Anything HTTP-only (curl, scripts)** | `POST https://brain-mcp.d-ma.be/query` with `{"query":"..."}` (auth via `BRAIN_MCP_TOKEN`) | `POST .../write` with `{"content":"...","filename":"..."}` |
| **Browser / human inspection** | `https://gitea.d-ma.be/mathias/hyperguild``knowledge/` and `wiki/` markdown files |
- **Scoping**: defaults to `public` collection; client projects filter to `{client}` + `public`.
- **Routing**: brain_answer's LLM uses berget.ai as primary, iguana ollama as
fallback. Both are configurable in the `supervisor/ingestion-deployment.yaml`
on the koala k3s cluster; don't hardcode local-only model names into the
berget URL (see knowledge entry on namespace mismatches).
### Quick reflex checks
If you find yourself about to say any of these out loud, you owe yourself a brain query first:
- "I think the issue might be..."
- "Let me try X and see..."
- "I'll just write a script to..."
- "This is probably a new bug..."
- "Has anyone done this before?" — *yes, probably, go check.*
## Client work rules
When working on a project tagged with a client name:
1. Never send code, data, or context to cloud APIs — use local models only
2. Never reference other client projects or their data
3. Keep all artifacts within the client's git org / directory
4. Treat everything as confidential unless told otherwise
## Harness-agnostic principles
This context is designed to work with any AI coding tool:
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
- Pi Coding Agent, Mistral Vibe, Antigravity
- Any tool that accepts a system prompt or reads a markdown context file
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
## How context propagates
Canonical sources of truth:
- Universal: `~/dev/.context/AGENT.md` (this file)
- Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
`.context/system-prompt.txt`
Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a
message telling you to stage the regenerated files.
Behavior rules in this file and per-project rules in `PROJECT.md` apply
unconditionally on every host, every harness.
## Engineering Skills
Shared engineering skills are available in `~/dev/.skills/`. Load on demand via the index.
See `~/dev/.skills/SKILLS_INDEX.md` for the full list with descriptions and "use when" triggers.
Key skills:
- **TDD**: always write tests first — load `tdd` skill
- **Code Review**: load `code-review` skill before any review
- **SOLID/Clean Code**: load `solid` or `clean-code` skill for design work
- **Problem first**: load `problem-analysis` skill before coding non-trivial features
---
# Project context
<!-- Canonical project context. Edit this, run `task context:sync`.
Root agent context from ~/dev/.context/AGENT.md is automatically
prepended for harnesses that don't walk the directory tree. -->
## Identity
- **Name**: supervisor
- **Owner**: Mathias
- **Client**: personal
- **Repo**:
- **Status**: active
## Stack
- **Primary language**: Go
- **UI layer**: HTMX + Templ (when applicable)
- **Fallback languages**: Python, TypeScript (justify in PR if used)
- **Build**: Task (taskfile.dev), not Make
- **Containers**: Docker (compose for dev, k3s for deploy)
- **Target infra**: koala (GPU workloads), iguana (services), flamingo (edge)
## Conventions
### Code style
- Go: follow `golines`, `gofumpt`, `golangci-lint` with project config
- Tests: table-driven, in `_test.go` next to source, `testify` for assertions
- Errors: wrap with `fmt.Errorf("operation: %w", err)`, no naked returns
- Naming: stdlib conventions, no stuttering (`http.Client` not `http.HTTPClient`)
### Architecture preferences
- Prefer standard library over frameworks (net/http over gin/echo)
- Dependency injection via constructor functions, not containers
- Configuration via environment variables, parsed at startup into a typed struct
- Structured logging via `slog`
### Git
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`
- Branch naming: `feat/short-description`, `fix/short-description`
- PRs: one concern per PR, description explains *why* not *what*
### Security
- No secrets in code, ever — use env vars or SOPS-encrypted files
- Client data never leaves local network unless explicitly cleared
- Dependencies: audit with `govulncheck` before adding
## MCP endpoints
Two MCP servers are live, both reachable over Tailscale and via HTTPS domain:
- **`brain`** at `https://brain-mcp.d-ma.be/mcp` (NodePort `koala:30330`) —
`brain_query`, `brain_write`, `brain_ingest`, `brain_ingest_raw`,
`brain_answer`, `brain_classify`, `session_log`. Hosted by the ingestion
service. Auth: Dex JWT (claude.ai OAuth) or static `BRAIN_MCP_TOKEN`.
- **`routing`** at `http://koala:30310/mcp` — Mode 2 routing pod. Advertises
`review`, `debug`, `retrospective`, `trainer`; per-call routes to local model
or Claude based on brain `/pass-rate`. Bearer auth via `ROUTING_MCP_TOKEN`
(opt-in). Only `mode client-local` registers this endpoint.
The supervisor MCP (`koala:30320`) was retired in Plan 7 (2026-05-12). Its
skill workers (`tdd`, `spec`) are now SKILL.md files; routed skills moved to
the routing pod; brain tools moved to the brain MCP.
The brain HTTP REST API (`/query`, `/write`, `/ingest`, `/ingest-raw`,
`/ingest-path`, `/backfill-refs`, `/pass-rate`) remains available on port 3300
for shell scripts and non-MCP clients.
`brain_answer(query)` performs BM25 retrieval + LLM synthesis (berget.ai
gemma4:31b → iguana fallback). `brain_classify(text)` infers doc type, title,
and tags. Both require `BRAIN_LLM_PRIMARY_URL` to be set in the ingestion pod.
## Agent instructions
When acting as a coding agent on this project:
1. Read this file and all `SKILL.md` files in `.skills/` before starting work
2. Run `task check` before committing (lint + test + vet)
3. If unsure about a convention, check `DECISIONS.md` or ask
4. Never modify files outside the project root without explicit permission
5. When adding a dependency, explain why in the commit message
6. For client projects: never send code or context to cloud APIs — use local models via LiteLLM

82
CLAUDE.md Normal file
View File

@@ -0,0 +1,82 @@
# 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

View File

@@ -44,6 +44,73 @@ Record *why* things are the way they are. Future-you will thank present-you.
**Consequences**: More operational complexity than Chroma, but isolation is non-negotiable for client work.
## 2026-04-22 — Hyperguild scope reset: drop parametric learning, simplify brain
**Context**: After shipping Phases 14 (MCP server, 6 skills, model orchestration, session logging, CD pipeline), we critically reviewed what was theater vs genuinely useful.
**Decisions**:
1. **Drop the parametric learning pipeline.** SFT/DPO/RL extraction, `brain/training-data/` directory structure, Axolotl/LLaMA-Factory fine-tuning loop — all cut. The loop requires thousands of high-quality examples to move the needle, which a solo consultant won't generate. Better base models ship faster than any fine-tuning effort could keep up with. This is a research project, not a productivity tool.
2. **Simplify the brain to plain markdown.** `brain/knowledge/` replaces `brain/wiki/ + brain/raw/ + brain/training-data/`. The trainer and retrospective workers write markdown entries. `brain_query` searches markdown. No ingestion pipeline, no tagging for significance review, no structured JSONL formats.
3. **Measure the escalation chain before assuming it's useful.** Local model (phi4) only belongs in a skill's chain if it passes Claude verification at a meaningful rate. Where it fails >70% of the time, it adds cost not value. Per-skill hit rate logging is the prerequisite to honest chain configuration.
4. **Keep what's real**: MCP tool surface, session logging with attempt records, tier detection, CD pipeline, bridge to Claude Code.
**What to build next** (in priority order):
- `brain_query` injection into skill handlers before spawning workers — this makes the declarative brain actually function
- `protocols.md` — behavioral contract injected into every worker prompt
- Per-skill pass rate logging and chain tuning
**Consequences**: Simpler system with a shorter feedback loop. The brain becomes real only when skill handlers query it. Training data ambitions deferred indefinitely — revisit if local model capabilities improve enough that fine-tuning becomes worthwhile.
---
## Plan 6: routing pod reuses internal/skills/{review,debug,retrospective,trainer}
Plan 6 (Mode 2 routing pod, 2026-05-04) introduces a second consumer of
the four cost-routable skill packages. The routing pod constructs each
skill via `<pkg>.New(Config{...})` and hands it `routing.Router.Run` as
the `CompleteFunc`.
**Preserved code (do not delete):**
- `internal/skills/{review,debug,retrospective,trainer}/`
- `internal/registry`, `internal/mcp`, `internal/exec/litellm.go`
- `internal/routing/`, `cmd/routing/`
---
## Plan 7: supervisor pod retired (2026-05-12)
**What was deleted:** `cmd/supervisor/`, `internal/skills/{tdd,spec}/`,
root `Dockerfile`, supervisor k8s manifests (Deployment, Service, Ingress,
NodePort 30320), `supervisor` entry removed from all `.mcp.json` configs.
**Coverage:** `tdd`/`spec` → SKILL.md files in `~/dev/.skills/`; `review`,
`debug`, `retrospective`, `trainer` → routing pod; `brain_*`/`session_log`
brain MCP; `tier``hyperguild tier` CLI.
---
## 2026-05-12 — brain_answer and brain_classify: LLM routing via berget.ai → iguana
**Context:** Brain MCP returned raw BM25 excerpts with no synthesis. Adding
LLM-backed tools enables Q&A and ingestion enrichment without a separate service.
**Decision:** Two new MCP tools in the ingestion service (`ingestion/internal/mcp/`):
- `brain_answer(query)` — BM25 top-10 → LLM synthesis → answer + sources
- `brain_classify(text)` — LLM classifies doc into type/title/tags
Primary LLM: berget.ai `gemma4:31b` (EU cloud, spend tokens while available).
Fallback: iguana `gemma4:31b` (local Ollama). Reranker deferred to follow-up.
Router lives in `ingestion/internal/llm.Router`; opt-in via `BRAIN_LLM_PRIMARY_URL`.
**Consequences:** Brain becomes a knowledge assistant, not just a search index.
When berget.ai tokens run out, flip `BRAIN_LLM_PRIMARY_URL` to iguana.
---
## 2026-04-08 — Mistral Vibe gets its own adapter
**Context**: Vibe doesn't read `AGENTS.md` — it uses `~/.vibe/prompts/` and `~/.vibe/agents/` with TOML config.
@@ -51,3 +118,52 @@ Record *why* things are the way they are. Future-you will thank present-you.
**Decision**: The root context-sync generates a `mathias.md` prompt and `mathias.toml` agent config in `~/.vibe/`. This is the one tool that needs a custom adapter path.
**Consequences**: Run `vibe --agent mathias` to use your conventions. Other Vibe users on the machine aren't affected.
---
## 2026-05-18 — project_create commits staging namespace directly to infra main
**Context:** `project_create` writes a k8s namespace manifest into the infra
repo so Flux brings up a staging environment for the new project. Initial
implementation pushed to a `staging/<name>` branch, which required manual PR
merge before Flux saw the namespace — defeating the "one tool call, project
exists, staging reconciling within 60s" goal.
**Decision:** Option A — commit directly to `main`. `callInfraCommit` passes
`branch: "main"` to gitea-mcp's `file_write_branch`; no PR, no merge step.
**Consequences:** Staging namespace appears in cluster within ~60s of the
`project_create` call. Consistent with project-wide TBD policy (CLAUDE.md):
commit directly to main, every commit deployable. Acceptable because the
manifest is a fresh namespace under `k3s/staging/<name>/` — isolated, low
blast-radius, and Flux will simply recreate it if the file is bad. Manual
review gating was friction for no compensating safety gain on experiment
namespaces.
---
## 2026-05-18 — pgvector over Qdrant for brain hybrid retrieval (supersedes 2026-04-08)
**Context:** The 2026-04-08 ADR chose Qdrant for vector store. Since then,
postgres18 with pgvector has been deployed in the `databases` namespace on
koala and is already the shared default for the rest of the project
(CLAUDE.md lists `pgvector (vector), BM25` as the primary search layer and
Qdrant only as a fallback "when >1M vectors or hybrid retrieval"). Qdrant
itself has never been deployed — `kubectl get` finds no pod, service, or
manifest. Standing up a new vector engine for a single consumer is friction
that the original ADR did not weigh.
**Decision:** Use pgvector for brain hybrid retrieval. Issue #8 — and any
follow-on embedding work — targets the existing `postgres18` instance:
- one table `brain_embeddings(path TEXT PRIMARY KEY, embedding VECTOR(768), updated_at TIMESTAMPTZ)`,
IVFFlat or HNSW index by feel once volume warrants
- BM25 stays as today (file walk + token frequency); cosine via pgvector
- hybrid scoring done in SQL or Go; pick once we measure
- nomic-embed-text on iguana ollama provides 768-dim vectors
**Consequences:** One database engine instead of two. Backups, monitoring,
and connection pooling already solved. Trade-off: pgvector at >1M vectors
or under hybrid-search load may underperform Qdrant — revisit only when
benchmarks hurt. The 2026-04-08 ADR is superseded for the brain use case;
Qdrant remains the noted fallback path in CLAUDE.md if scale demands it.

30
Dockerfile.routing Normal file
View File

@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# ── Build stage ───────────────────────────────────────────────────────────────
FROM golang:1.26-bookworm AS builder
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o /out/routing ./cmd/routing
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM gcr.io/distroless/base-debian12
COPY --from=builder /out/routing /usr/local/bin/routing
COPY config/ /app/config/
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
ENV ROUTING_PORT=3210
EXPOSE 3210
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/routing"]

View File

@@ -1,2 +1,2 @@
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 go run ./cmd/server/
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 go run ./cmd/supervisor/
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 INGEST_SVC_URL=http://localhost:3300 go run ./cmd/supervisor/

View File

@@ -10,10 +10,12 @@ into a searchable brain.
```
Your Claude Code session (in any project)
│ MCP tools (over stdio bridge → HTTP)
supervisor :3200 — skill workers: tdd, retrospective
ingestion :3300 — brain HTTP API: query wiki, write notes
│ MCP over HTTP (Tailscale)
├──▶ supervisor :3200 (NodePort 30320 on koala) — skill workers: tdd, debug, spec, …
├──▶ routing :3210 (NodePort 30310 on koala) — Mode 2 only: review, debug, retrospective, trainer
└──▶ brain :3300 (NodePort 30330 on koala) — brain_query, brain_write, brain_ingest, session_log
└─ also serves the legacy REST endpoints (/query, /write, /ingest, …)
brain/
@@ -55,18 +57,28 @@ Create `.mcp.json` in your project root:
{
"mcpServers": {
"supervisor": {
"command": "/Users/mathias/dev/AI/supervisor/bin/supervisor-bridge",
"env": {
"SUPERVISOR_URL": "http://localhost:3200/mcp"
}
"type": "http",
"url": "http://koala:30320/mcp"
},
"brain": {
"type": "http",
"url": "http://koala:30330/mcp"
}
}
}
```
Build the bridge binary once: `task bridge:build`
Two MCP servers are exposed today, both reachable over Tailscale:
Then open Claude Code in your project — run `/mcp` to confirm `supervisor` is listed.
- **`supervisor`** at `koala:30320` — skill workers (`tdd_red/green/refactor`,
`review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`).
- **`brain`** at `koala:30330` — knowledge access (`brain_query`, `brain_write`,
`brain_ingest`, `brain_ingest_raw`) and `session_log`. Hosted by the ingestion
service directly, no separate pod.
No local binary or stdio shim is required — Claude Code talks to both via HTTP.
Open Claude Code in your project — run `/mcp` to confirm both servers are listed.
## A typical TDD session
@@ -100,6 +112,17 @@ The supervisor probes connectivity at call time:
| `SUPERVISOR_SESSIONS_DIR` | `./brain/sessions` | JSONL session logs |
| `INGEST_BASE_URL` | `http://localhost:3300` | Supervisor → ingestion |
| `LITELLM_BASE_URL` | — | LiteLLM proxy for Tier 2 model routing |
| `SUPERVISOR_MCP_TOKEN` | — | Optional bearer token for the supervisor MCP HTTP endpoint; when empty, no auth is enforced |
| `ROUTING_PORT` | `3210` | Routing pod's listen port |
| `ROUTING_MCP_TOKEN` | — | Optional bearer token for the routing MCP HTTP endpoint |
| `BRAIN_URL` | `http://ingestion.supervisor:3300` | Routing pod → brain (in-cluster) |
| `HYPERGUILD_FAST_MODEL` | `koala/qwen35-9b-fast` | Fast model for high-pass-rate skill calls |
| `HYPERGUILD_THINKING_MODEL` | `iguana/gemma4-26b` | Thinking model for low-pass-rate skill calls |
| `HYPERGUILD_ROUTE_LOCAL_FLOOR` | `0.90` | At/above pass rate, route to fast model |
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below pass rate, route to thinking model. Between CEIL and FLOOR is the sample band. |
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill pass-rate cache TTL |
> **Operator note:** LiteLLM at `LITELLM_BASE_URL` must register both `HYPERGUILD_FAST_MODEL` and `HYPERGUILD_THINKING_MODEL` for routing to do useful work. If a model is missing, LiteLLM returns 4xx, the routing pod's fast route fails, the fail-open retry on the thinking model likely also fails (since both are missing), and the only signal is `final_status: "fail"` on `_routing` entries in the brain.
## Phase 2 (planned)

View File

@@ -12,9 +12,6 @@ tasks:
desc: Regenerate all harness-specific context files
cmds:
- bash scripts/context-sync.sh
sources:
- .context/PROJECT.md
- .skills/*/SKILL.md
context:sync:claude:
cmds: [bash scripts/context-sync.sh claude]
@@ -42,6 +39,22 @@ tasks:
cmds:
- go run ./cmd/supervisor
hyperguild:dev:
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
cmds:
- go run ./cmd/hyperguild {{.CLI_ARGS}}
hyperguild:build:
desc: Build the hyperguild binary into ./bin/hyperguild
cmds:
- mkdir -p bin
- go build -o bin/hyperguild ./cmd/hyperguild
hyperguild:install:
desc: Install hyperguild into $GOBIN
cmds:
- go install ./cmd/hyperguild
ingestion:dev:
desc: Run ingestion server in development mode
dir: ingestion
@@ -57,7 +70,6 @@ tasks:
desc: Build all binaries
cmds:
- task: supervisor:build
- task: bridge:build
- task: ingestion:build
supervisor:build:
@@ -65,11 +77,6 @@ tasks:
cmds:
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
bridge:build:
desc: Build stdio↔HTTP bridge for Claude Code MCP integration
cmds:
- go build -trimpath -ldflags="-s -w" -o bin/supervisor-bridge ./cmd/bridge
ingestion:build:
desc: Build ingestion server binary
dir: ingestion
@@ -79,8 +86,20 @@ tasks:
# ── Quality ────────────────────────────────────────────────────────────────
check:
desc: Run all checks (lint + test + vet) across all modules
desc: Run all checks (context freshness + lint + test + vet) across all modules
cmds:
- task: context:sync
- cmd: |
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt 2>/dev/null)
if [ -n "$drift" ]; then
echo "ERROR: derived adapters drifted from canonical context." >&2
echo "$drift" >&2
echo "" >&2
echo "Run: git add AGENTS.md CLAUDE.md .cursorrules .aider.conventions.md .context/system-prompt.txt" >&2
echo " git commit -m 'chore: re-sync context adapters'" >&2
exit 1
fi
echo "✓ context: canonical and adapters are in sync"
- task: lint
- task: test
- task: vet
@@ -109,6 +128,11 @@ tasks:
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
smoke:routing:
desc: Boot the routing pod against live LiteLLM + brain and verify _routing logs land
cmds:
- bash scripts/smoke-routing.sh
# ── Git / Release ──────────────────────────────────────────────────────────
tag:

View File

@@ -0,0 +1,167 @@
# baseline-pre-fix — 20 questions, k=5
top-1 hit rate: 4/20 = 20%
top-3 hit rate: 13/20 = 65%
## per-question detail
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
q: how do I stop dex from logging users out on every pod restart?
1. homelab-network-perimeter-model
2. 2026-05-12-koala-machine-state
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
4. infra-litellm-absorption-2026-05-16
5. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
★ rank=1 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
1. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
2. infra-litellm-absorption-2026-05-16
3. brain-mcp-activation-runbook
4. extension-version-lags-platform-major-upgrade
5. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
★ rank=1 expected=homelab-network-perimeter-model
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
1. homelab-network-perimeter-model <-- expected
2. qwen3-thinking-model-empty-content-trap
3. mcpclient-empty-token-silent-401-envfrom-missing-key
4. 2026-05-12-koala-machine-state
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=3 expected=exit-255-unknown-reason-not-oom
q: what does container exit code 255 with reason Unknown mean?
1. qwen3-thinking-model-empty-content-trap
2. infra-litellm-absorption-2026-05-16
3. exit-255-unknown-reason-not-oom <-- expected
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=3 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
q: can gitea push-mirror create the github repo automatically?
1. infra-litellm-absorption-2026-05-16
2. Autoresearch
3. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
4. adr-new-project-gitea-first-github-mirror
5. adr-github-as-primary-remote
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
q: a flux kustomization is stuck after I removed a resource — why?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-12-koala-machine-state
3. homelab-architecture-principles-2026-05
4. gitea-mcp: full stack shipped end-to-end (2026-05-05)
5. k8s-configmap-mount-no-reload-needs-pod-restart
· rank=2 expected=go-bytes-buffer-bytes-reset-aliasing-trap
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
1. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
2. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
3. homelab-security-chains-not-bugs
4. training-on-rtx-5070-pretraining-vs-finetuning
5. Hash Encoding
★ rank=1 expected=homelab-architecture-principles-2026-05
q: what are the homelab architecture principles from may 2026?
1. homelab-architecture-principles-2026-05 <-- expected
2. homelab-network-perimeter-model
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
4. homelab-core-glossary
5. 2026-05-12-koala-machine-state
✗ rank=0 expected=2026-05-04-sops-age-key-from-flux-cluster
q: where does the sops age private key live in the cluster?
1. 2026-05-12-koala-machine-state
2. homelab-network-perimeter-model
3. postgres-least-privilege-migration-tenant-grant-bypass-2026-05
4. brain-mcp-activation-runbook
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
✗ rank=0 expected=grafana-dashboards-as-code-not-ui-state
q: why do my grafana dashboards disappear after a pod restart?
1. infra-litellm-absorption-2026-05-16
2. 2026-05-12-koala-machine-state
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
4. brain-mcp-activation-runbook
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
· rank=2 expected=double-diamond-methodology
q: what is the double diamond methodology?
1. Harnessing the Power of Hash Encoding for Categorical Data in Data Science
2. double-diamond-methodology <-- expected
3. unified-methodology-diamond-futures-autoresearch
4. futures-thinking-extended-double-diamond
5. insight-exploration-as-diamond-1
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
q: my MCP server works from claude code but fails on claude.ai — what's different?
1. qwen3-thinking-model-empty-content-trap
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. finding-github-mcp-claudeai-vs-claudecode
· rank=2 expected=homelab-security-chains-not-bugs
q: how should I rate security findings — isolated bugs or exploit chains?
1. homelab-network-perimeter-model
2. homelab-security-chains-not-bugs <-- expected
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
4. policy-audit-mode-blocks-nothing
5. homelab-document-accepted-risk-to-break-audit-cycle
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
q: how should canonical context files relate to derived adapter files?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
3. 2026-05-12-koala-machine-state
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=2 expected=homelab-core-glossary
q: what is the homelab core vocabulary glossary?
1. homelab-architecture-principles-2026-05
2. homelab-core-glossary <-- expected
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
4. 2026-05-12-koala-machine-state
5. Autoresearch
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
q: which models on koala llama-swap actually emit native tool_calls correctly?
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
2. 2026-05-12-koala-machine-state
3. infra-litellm-absorption-2026-05-16
4. training-on-rtx-5070-pretraining-vs-finetuning
5. qwen3-thinking-model-empty-content-trap
✗ rank=0 expected=qwen35-9b-fast
q: what is qwen35-9b-fast and what's it used for?
1. koala-llama-swap-native-tool-calls-survey-2026-05
2. qwen3-thinking-model-empty-content-trap
3. Qwen35-9b-fast
4. infra-litellm-absorption-2026-05-16
5. 2026-05-12-koala-machine-state
✗ rank=0 expected=go-defer-errcheck-body-close
q: in go, how do I prevent defer body close from silently dropping errors?
1. infra-litellm-absorption-2026-05-16
2. homelab-network-perimeter-model
3. go-bytes-buffer-bytes-reset-aliasing-trap
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. brain-mcp-activation-runbook
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
1. 2026-05-12-koala-machine-state
2. homelab-core-glossary
3. brain-mcp-activation-runbook
4. koala-llama-swap-native-tool-calls-survey-2026-05
5. infra-litellm-absorption-2026-05-16
? rank=4 expected=adr-new-project-gitea-first-github-mirror
q: what's the new-project ADR — is it gitea-first or github-first?
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
2. gitea-mcp: full stack shipped end-to-end (2026-05-05)
3. mcp-tool-design-get-needs-list-partner
4. adr-new-project-gitea-first-github-mirror <-- expected
5. 2026-05-04-gitea-mcp-build-session

167
brain/eval/post-fix.txt Normal file
View File

@@ -0,0 +1,167 @@
# post-fix — 20 questions, k=5
top-1 hit rate: 4/20 = 20%
top-3 hit rate: 14/20 = 70%
## per-question detail
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
q: how do I stop dex from logging users out on every pod restart?
1. homelab-network-perimeter-model
2. 2026-05-12-koala-machine-state
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
4. infra-litellm-absorption-2026-05-16
5. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
★ rank=1 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
1. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
2. infra-litellm-absorption-2026-05-16
3. brain-mcp-activation-runbook
4. extension-version-lags-platform-major-upgrade
5. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
★ rank=1 expected=homelab-network-perimeter-model
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
1. homelab-network-perimeter-model <-- expected
2. qwen3-thinking-model-empty-content-trap
3. mcpclient-empty-token-silent-401-envfrom-missing-key
4. 2026-05-12-koala-machine-state
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=3 expected=exit-255-unknown-reason-not-oom
q: what does container exit code 255 with reason Unknown mean?
1. qwen3-thinking-model-empty-content-trap
2. infra-litellm-absorption-2026-05-16
3. exit-255-unknown-reason-not-oom <-- expected
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=3 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
q: can gitea push-mirror create the github repo automatically?
1. infra-litellm-absorption-2026-05-16
2. Autoresearch
3. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
4. adr-new-project-gitea-first-github-mirror
5. adr-github-as-primary-remote
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
q: a flux kustomization is stuck after I removed a resource — why?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-12-koala-machine-state
3. homelab-architecture-principles-2026-05
4. gitea-mcp: full stack shipped end-to-end (2026-05-05)
5. k8s-configmap-mount-no-reload-needs-pod-restart
· rank=2 expected=go-bytes-buffer-bytes-reset-aliasing-trap
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
1. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
2. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
3. homelab-security-chains-not-bugs
4. training-on-rtx-5070-pretraining-vs-finetuning
5. Hash Encoding
★ rank=1 expected=homelab-architecture-principles-2026-05
q: what are the homelab architecture principles from may 2026?
1. homelab-architecture-principles-2026-05 <-- expected
2. homelab-network-perimeter-model
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
4. homelab-core-glossary
5. 2026-05-12-koala-machine-state
✗ rank=0 expected=2026-05-04-sops-age-key-from-flux-cluster
q: where does the sops age private key live in the cluster?
1. 2026-05-12-koala-machine-state
2. homelab-network-perimeter-model
3. postgres-least-privilege-migration-tenant-grant-bypass-2026-05
4. brain-mcp-activation-runbook
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
✗ rank=0 expected=grafana-dashboards-as-code-not-ui-state
q: why do my grafana dashboards disappear after a pod restart?
1. infra-litellm-absorption-2026-05-16
2. 2026-05-12-koala-machine-state
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
4. brain-mcp-activation-runbook
5. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
· rank=2 expected=double-diamond-methodology
q: what is the double diamond methodology?
1. Harnessing the Power of Hash Encoding for Categorical Data in Data Science
2. double-diamond-methodology <-- expected
3. unified-methodology-diamond-futures-autoresearch
4. futures-thinking-extended-double-diamond
5. insight-exploration-as-diamond-1
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
q: my MCP server works from claude code but fails on claude.ai — what's different?
1. qwen3-thinking-model-empty-content-trap
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. finding-github-mcp-claudeai-vs-claudecode
· rank=2 expected=homelab-security-chains-not-bugs
q: how should I rate security findings — isolated bugs or exploit chains?
1. homelab-network-perimeter-model
2. homelab-security-chains-not-bugs <-- expected
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
4. policy-audit-mode-blocks-nothing
5. homelab-document-accepted-risk-to-break-audit-cycle
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
q: how should canonical context files relate to derived adapter files?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
3. 2026-05-12-koala-machine-state
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=2 expected=homelab-core-glossary
q: what is the homelab core vocabulary glossary?
1. homelab-architecture-principles-2026-05
2. homelab-core-glossary <-- expected
3. Claude Managed Agents — architecture notes relevant to homelab agent platform
4. 2026-05-12-koala-machine-state
5. Autoresearch
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
q: which models on koala llama-swap actually emit native tool_calls correctly?
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
2. 2026-05-12-koala-machine-state
3. infra-litellm-absorption-2026-05-16
4. training-on-rtx-5070-pretraining-vs-finetuning
5. qwen3-thinking-model-empty-content-trap
· rank=2 expected=qwen35-9b-fast
q: what is qwen35-9b-fast and what's it used for?
1. koala-llama-swap-native-tool-calls-survey-2026-05
2. qwen35-9b-fast <-- expected
3. qwen3-thinking-model-empty-content-trap
4. infra-litellm-absorption-2026-05-16
5. 2026-05-12-koala-machine-state
✗ rank=0 expected=go-defer-errcheck-body-close
q: in go, how do I prevent defer body close from silently dropping errors?
1. infra-litellm-absorption-2026-05-16
2. homelab-network-perimeter-model
3. go-bytes-buffer-bytes-reset-aliasing-trap
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. brain-mcp-activation-runbook
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
1. 2026-05-12-koala-machine-state
2. homelab-core-glossary
3. brain-mcp-activation-runbook
4. koala-llama-swap-native-tool-calls-survey-2026-05
5. infra-litellm-absorption-2026-05-16
? rank=4 expected=adr-new-project-gitea-first-github-mirror
q: what's the new-project ADR — is it gitea-first or github-first?
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
2. gitea-mcp: full stack shipped end-to-end (2026-05-05)
3. mcp-tool-design-get-needs-list-partner
4. adr-new-project-gitea-first-github-mirror <-- expected
5. 2026-05-04-gitea-mcp-build-session

167
brain/eval/post-m4.txt Normal file
View File

@@ -0,0 +1,167 @@
# post-m4-tier-weighting — 20 questions, k=5
top-1 hit rate: 6/20 = 30%
top-3 hit rate: 15/20 = 75%
## per-question detail
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
q: how do I stop dex from logging users out on every pod restart?
1. homelab-network-perimeter-model
2. 2026-05-12-koala-machine-state
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
4. infra-litellm-absorption-2026-05-16
5. k8s-configmap-mount-no-reload-needs-pod-restart
· rank=2 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
1. infra-litellm-absorption-2026-05-16
2. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
3. extension-version-lags-platform-major-upgrade
4. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
5. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
★ rank=1 expected=homelab-network-perimeter-model
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
1. homelab-network-perimeter-model <-- expected
2. qwen3-thinking-model-empty-content-trap
3. mcpclient-empty-token-silent-401-envfrom-missing-key
4. 2026-05-12-koala-machine-state
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=3 expected=exit-255-unknown-reason-not-oom
q: what does container exit code 255 with reason Unknown mean?
1. qwen3-thinking-model-empty-content-trap
2. infra-litellm-absorption-2026-05-16
3. exit-255-unknown-reason-not-oom <-- expected
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=2 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
q: can gitea push-mirror create the github repo automatically?
1. infra-litellm-absorption-2026-05-16
2. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
3. adr-new-project-gitea-first-github-mirror
4. adr-github-as-primary-remote
5. 2026-05-12-koala-machine-state
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
q: a flux kustomization is stuck after I removed a resource — why?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-12-koala-machine-state
3. homelab-architecture-principles-2026-05
4. k8s-configmap-mount-no-reload-needs-pod-restart
5. training-on-rtx-5070-pretraining-vs-finetuning
★ rank=1 expected=go-bytes-buffer-bytes-reset-aliasing-trap
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
1. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
2. homelab-security-chains-not-bugs
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
4. training-on-rtx-5070-pretraining-vs-finetuning
5. flux-healthcheck-stale-on-resource-removal
★ rank=1 expected=homelab-architecture-principles-2026-05
q: what are the homelab architecture principles from may 2026?
1. homelab-architecture-principles-2026-05 <-- expected
2. homelab-network-perimeter-model
3. homelab-core-glossary
4. 2026-05-12-koala-machine-state
5. pattern-reddit-tmux-multiagent-conductor
? rank=4 expected=2026-05-04-sops-age-key-from-flux-cluster
q: where does the sops age private key live in the cluster?
1. 2026-05-12-koala-machine-state
2. homelab-network-perimeter-model
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
4. 2026-05-04-sops-age-key-from-flux-cluster <-- expected
5. homelab-security-chains-not-bugs
★ rank=1 expected=grafana-dashboards-as-code-not-ui-state
q: why do my grafana dashboards disappear after a pod restart?
1. grafana-dashboards-as-code-not-ui-state <-- expected
2. infra-litellm-absorption-2026-05-16
3. 2026-05-12-koala-machine-state
4. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
5. k8s-configmap-mount-no-reload-needs-pod-restart
★ rank=1 expected=double-diamond-methodology
q: what is the double diamond methodology?
1. double-diamond-methodology <-- expected
2. unified-methodology-diamond-futures-autoresearch
3. futures-thinking-extended-double-diamond
4. insight-exploration-as-diamond-1
5. workflow-idea-to-running-service
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
q: my MCP server works from claude code but fails on claude.ai — what's different?
1. qwen3-thinking-model-empty-content-trap
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. finding-github-mcp-claudeai-vs-claudecode
· rank=2 expected=homelab-security-chains-not-bugs
q: how should I rate security findings — isolated bugs or exploit chains?
1. homelab-network-perimeter-model
2. homelab-security-chains-not-bugs <-- expected
3. policy-audit-mode-blocks-nothing
4. homelab-document-accepted-risk-to-break-audit-cycle
5. audit-shortcut-tls-blocks-zero-equals-edge-only
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
q: how should canonical context files relate to derived adapter files?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
3. 2026-05-12-koala-machine-state
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=2 expected=homelab-core-glossary
q: what is the homelab core vocabulary glossary?
1. homelab-architecture-principles-2026-05
2. homelab-core-glossary <-- expected
3. 2026-05-12-koala-machine-state
4. flux-kustomization-depends-on-bootstrap-ordering
5. brain-ingest-ntfy-service
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
q: which models on koala llama-swap actually emit native tool_calls correctly?
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
2. 2026-05-12-koala-machine-state
3. infra-litellm-absorption-2026-05-16
4. training-on-rtx-5070-pretraining-vs-finetuning
5. qwen3-thinking-model-empty-content-trap
✗ rank=0 expected=qwen35-9b-fast
q: what is qwen35-9b-fast and what's it used for?
1. koala-llama-swap-native-tool-calls-survey-2026-05
2. qwen3-thinking-model-empty-content-trap
3. infra-litellm-absorption-2026-05-16
4. 2026-05-12-koala-machine-state
5. index
✗ rank=0 expected=go-defer-errcheck-body-close
q: in go, how do I prevent defer body close from silently dropping errors?
1. homelab-network-perimeter-model
2. infra-litellm-absorption-2026-05-16
3. go-bytes-buffer-bytes-reset-aliasing-trap
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. koala-llama-swap-native-tool-calls-survey-2026-05
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
1. 2026-05-12-koala-machine-state
2. homelab-core-glossary
3. koala-llama-swap-native-tool-calls-survey-2026-05
4. infra-litellm-absorption-2026-05-16
5. homelab-architecture-principles-2026-05
· rank=3 expected=adr-new-project-gitea-first-github-mirror
q: what's the new-project ADR — is it gitea-first or github-first?
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
2. mcp-tool-design-get-needs-list-partner
3. adr-new-project-gitea-first-github-mirror <-- expected
4. 2026-05-04-gitea-mcp-build-session
5. adr-local-dev-vs-hyperguild-new-project

167
brain/eval/post-m4b.txt Normal file
View File

@@ -0,0 +1,167 @@
# post-m4b-entities-promoted — 20 questions, k=5
top-1 hit rate: 7/20 = 35%
top-3 hit rate: 16/20 = 80%
## per-question detail
· rank=3 expected=dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
q: how do I stop dex from logging users out on every pod restart?
1. homelab-network-perimeter-model
2. 2026-05-12-koala-machine-state
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart <-- expected
4. infra-litellm-absorption-2026-05-16
5. k8s-configmap-mount-no-reload-needs-pod-restart
· rank=2 expected=postgres-least-privilege-migration-tenant-grant-bypass-2026-05
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
1. infra-litellm-absorption-2026-05-16
2. postgres-least-privilege-migration-tenant-grant-bypass-2026-05 <-- expected
3. extension-version-lags-platform-major-upgrade
4. ntfy-deny-all-rollout-ordering-keep-alert-pipeline-live-during-auth-flip
5. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
★ rank=1 expected=homelab-network-perimeter-model
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
1. homelab-network-perimeter-model <-- expected
2. qwen3-thinking-model-empty-content-trap
3. mcpclient-empty-token-silent-401-envfrom-missing-key
4. 2026-05-12-koala-machine-state
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=3 expected=exit-255-unknown-reason-not-oom
q: what does container exit code 255 with reason Unknown mean?
1. qwen3-thinking-model-empty-content-trap
2. infra-litellm-absorption-2026-05-16
3. exit-255-unknown-reason-not-oom <-- expected
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=2 expected=gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
q: can gitea push-mirror create the github repo automatically?
1. infra-litellm-absorption-2026-05-16
2. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo <-- expected
3. adr-new-project-gitea-first-github-mirror
4. adr-github-as-primary-remote
5. 2026-05-12-koala-machine-state
✗ rank=0 expected=flux-healthcheck-stale-on-resource-removal
q: a flux kustomization is stuck after I removed a resource — why?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-12-koala-machine-state
3. homelab-architecture-principles-2026-05
4. k8s-configmap-mount-no-reload-needs-pod-restart
5. training-on-rtx-5070-pretraining-vs-finetuning
★ rank=1 expected=go-bytes-buffer-bytes-reset-aliasing-trap
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
1. go-bytes-buffer-bytes-reset-aliasing-trap <-- expected
2. homelab-security-chains-not-bugs
3. Financial Sentiment Analysis on Stock Market Headlines With FinBERT & HuggingFace
4. training-on-rtx-5070-pretraining-vs-finetuning
5. flux-healthcheck-stale-on-resource-removal
★ rank=1 expected=homelab-architecture-principles-2026-05
q: what are the homelab architecture principles from may 2026?
1. homelab-architecture-principles-2026-05 <-- expected
2. homelab-network-perimeter-model
3. homelab-core-glossary
4. 2026-05-12-koala-machine-state
5. pattern-reddit-tmux-multiagent-conductor
? rank=4 expected=2026-05-04-sops-age-key-from-flux-cluster
q: where does the sops age private key live in the cluster?
1. 2026-05-12-koala-machine-state
2. homelab-network-perimeter-model
3. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
4. 2026-05-04-sops-age-key-from-flux-cluster <-- expected
5. homelab-security-chains-not-bugs
★ rank=1 expected=grafana-dashboards-as-code-not-ui-state
q: why do my grafana dashboards disappear after a pod restart?
1. grafana-dashboards-as-code-not-ui-state <-- expected
2. infra-litellm-absorption-2026-05-16
3. 2026-05-12-koala-machine-state
4. dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
5. k8s-configmap-mount-no-reload-needs-pod-restart
★ rank=1 expected=double-diamond-methodology
q: what is the double diamond methodology?
1. double-diamond-methodology <-- expected
2. unified-methodology-diamond-futures-autoresearch
3. futures-thinking-extended-double-diamond
4. insight-exploration-as-diamond-1
5. workflow-idea-to-running-service
· rank=3 expected=2026-05-04-mcp-transport-version-claude-ai-strict
q: my MCP server works from claude code but fails on claude.ai — what's different?
1. qwen3-thinking-model-empty-content-trap
2. mcp-resource-url-empty-breaks-claude-ai-discovery-silently
3. 2026-05-04-mcp-transport-version-claude-ai-strict <-- expected
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. finding-github-mcp-claudeai-vs-claudecode
· rank=2 expected=homelab-security-chains-not-bugs
q: how should I rate security findings — isolated bugs or exploit chains?
1. homelab-network-perimeter-model
2. homelab-security-chains-not-bugs <-- expected
3. policy-audit-mode-blocks-nothing
4. homelab-document-accepted-risk-to-break-audit-cycle
5. audit-shortcut-tls-blocks-zero-equals-edge-only
· rank=2 expected=2026-05-03-canonical-vs-derived-context-flow
q: how should canonical context files relate to derived adapter files?
1. qwen3-thinking-model-empty-content-trap
2. 2026-05-03-canonical-vs-derived-context-flow <-- expected
3. 2026-05-12-koala-machine-state
4. 2026-05-04-claude-ai-custom-mcp-connectors
5. koala-llama-swap-native-tool-calls-survey-2026-05
· rank=2 expected=homelab-core-glossary
q: what is the homelab core vocabulary glossary?
1. homelab-architecture-principles-2026-05
2. homelab-core-glossary <-- expected
3. 2026-05-12-koala-machine-state
4. qwen35-9b-fast
5. flux-kustomization-depends-on-bootstrap-ordering
★ rank=1 expected=koala-llama-swap-native-tool-calls-survey-2026-05
q: which models on koala llama-swap actually emit native tool_calls correctly?
1. koala-llama-swap-native-tool-calls-survey-2026-05 <-- expected
2. 2026-05-12-koala-machine-state
3. infra-litellm-absorption-2026-05-16
4. training-on-rtx-5070-pretraining-vs-finetuning
5. qwen3-thinking-model-empty-content-trap
★ rank=1 expected=qwen35-9b-fast
q: what is qwen35-9b-fast and what's it used for?
1. qwen35-9b-fast <-- expected
2. koala-llama-swap-native-tool-calls-survey-2026-05
3. qwen3-thinking-model-empty-content-trap
4. infra-litellm-absorption-2026-05-16
5. 2026-05-12-koala-machine-state
✗ rank=0 expected=go-defer-errcheck-body-close
q: in go, how do I prevent defer body close from silently dropping errors?
1. homelab-network-perimeter-model
2. infra-litellm-absorption-2026-05-16
3. go-bytes-buffer-bytes-reset-aliasing-trap
4. mcpclient-empty-token-silent-401-envfrom-missing-key
5. koala-llama-swap-native-tool-calls-survey-2026-05
✗ rank=0 expected=hyperguild-level3-pipeline-rewrite
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
1. 2026-05-12-koala-machine-state
2. homelab-core-glossary
3. koala-llama-swap-native-tool-calls-survey-2026-05
4. infra-litellm-absorption-2026-05-16
5. homelab-architecture-principles-2026-05
· rank=3 expected=adr-new-project-gitea-first-github-mirror
q: what's the new-project ADR — is it gitea-first or github-first?
1. gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
2. mcp-tool-design-get-needs-list-partner
3. adr-new-project-gitea-first-github-mirror <-- expected
4. 2026-05-04-gitea-mcp-build-session
5. adr-local-dev-vs-hyperguild-new-project

76
brain/eval/qa-2026-05.md Normal file
View File

@@ -0,0 +1,76 @@
# Brain retrieval eval set — 2026-05-24
20 hand-authored Q→expected-top-1-slug pairs. Used by `score.sh` to
measure brain_query top-1 + top-3 hit rate against the live brain.
Authoring rules:
- Each question maps to **one** clear-best entry. Avoid ambiguous
questions where multiple slugs could be the right answer.
- Questions are phrased the way a future-me would actually ask, not
the way the entry's title reads. Some lexical distance is the point.
- `expected` is the slug as stored in `brain_entities.slug`. Update
if the slug renames.
## Pairs
```
q: how do I stop dex from logging users out on every pod restart?
expected: dex-in-memory-storage-wipes-oauth-tokens-on-every-pod-restart
q: my postgres-exporter broke after revoking PUBLIC CONNECT — why?
expected: postgres-least-privilege-migration-tenant-grant-bypass-2026-05
q: when is a NodePort acceptable vs needing a public ingress with bearer gate?
expected: homelab-network-perimeter-model
q: what does container exit code 255 with reason Unknown mean?
expected: exit-255-unknown-reason-not-oom
q: can gitea push-mirror create the github repo automatically?
expected: gitea-push-mirror-cannot-create-remote-repo-needs-pre-existing-github-repo
q: a flux kustomization is stuck after I removed a resource — why?
expected: flux-healthcheck-stale-on-resource-removal
q: the bytes buffer aliasing trap with Reset in a loop — what's the bug?
expected: go-bytes-buffer-bytes-reset-aliasing-trap
q: what are the homelab architecture principles from may 2026?
expected: homelab-architecture-principles-2026-05
q: where does the sops age private key live in the cluster?
expected: 2026-05-04-sops-age-key-from-flux-cluster
q: why do my grafana dashboards disappear after a pod restart?
expected: grafana-dashboards-as-code-not-ui-state
q: what is the double diamond methodology?
expected: double-diamond-methodology
q: my MCP server works from claude code but fails on claude.ai — what's different?
expected: 2026-05-04-mcp-transport-version-claude-ai-strict
q: how should I rate security findings — isolated bugs or exploit chains?
expected: homelab-security-chains-not-bugs
q: how should canonical context files relate to derived adapter files?
expected: 2026-05-03-canonical-vs-derived-context-flow
q: what is the homelab core vocabulary glossary?
expected: homelab-core-glossary
q: which models on koala llama-swap actually emit native tool_calls correctly?
expected: koala-llama-swap-native-tool-calls-survey-2026-05
q: what is qwen35-9b-fast and what's it used for?
expected: qwen35-9b-fast
q: in go, how do I prevent defer body close from silently dropping errors?
expected: go-defer-errcheck-body-close
q: what was the level 3 rewrite of hyperguild's ingestion pipeline?
expected: hyperguild-level3-pipeline-rewrite
q: what's the new-project ADR — is it gitea-first or github-first?
expected: adr-new-project-gitea-first-github-mirror
```

131
brain/eval/score.py Normal file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Score brain_query against the qa-2026-05.md eval set.
Reads `q:` / `expected:` pairs, calls brain_query MCP for each, records
top-1 + top-3 hit rate. Run:
BRAIN_MCP_TOKEN=$(grep '^export BRAIN_MCP_TOKEN=' ~/.llmkeys | cut -d= -f2-) \\
python3 score.py qa-2026-05.md
Optionally pass --baseline <name> to save the result as a labeled run.
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.request
ENDPOINT = "https://brain-mcp.d-ma.be/mcp"
def load_pairs(path):
pairs = []
q = None
with open(path) as f:
for line in f:
line = line.rstrip()
if line.startswith("q:"):
q = line[2:].strip()
elif line.startswith("expected:") and q is not None:
expected = line[len("expected:"):].strip()
pairs.append((q, expected))
q = None
return pairs
def brain_query(token, query, k=5):
body = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "brain_query", "arguments": {"query": query, "k": k}},
}).encode()
req = urllib.request.Request(
ENDPOINT,
data=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read().decode()
for line in raw.splitlines():
if line.startswith("data:"):
raw = line[5:].strip()
break
d = json.loads(raw)
if "error" in d:
raise RuntimeError(d["error"])
text = d["result"]["content"][0]["text"]
return json.loads(text).get("results", [])
def slug_of(result):
# `title` mirrors the slug in brain_entities for normal entries.
# Fall back to basename(path) if title is missing.
t = result.get("title", "")
if t:
return t
p = result.get("path", "")
return re.sub(r"\.md$", "", os.path.basename(p))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("evalset")
ap.add_argument("--baseline", default="run")
ap.add_argument("--k", type=int, default=5)
args = ap.parse_args()
token = os.environ.get("BRAIN_MCP_TOKEN")
if not token:
sys.exit("BRAIN_MCP_TOKEN not set")
pairs = load_pairs(args.evalset)
if not pairs:
sys.exit(f"no pairs in {args.evalset}")
print(f"# {args.baseline}{len(pairs)} questions, k={args.k}")
print()
hits1 = 0
hits3 = 0
detail = []
for q, expected in pairs:
try:
results = brain_query(token, q, k=args.k)
except Exception as e:
detail.append((q, expected, [], f"ERR {e}"))
continue
slugs = [slug_of(r) for r in results]
rank = slugs.index(expected) + 1 if expected in slugs else 0
h1 = 1 if rank == 1 else 0
h3 = 1 if 0 < rank <= 3 else 0
hits1 += h1
hits3 += h3
detail.append((q, expected, slugs, rank))
total = len(pairs)
print(f"top-1 hit rate: {hits1}/{total} = {100*hits1/total:.0f}%")
print(f"top-3 hit rate: {hits3}/{total} = {100*hits3/total:.0f}%")
print()
print("## per-question detail")
print()
for q, expected, slugs, rank in detail:
marker = {0: "", 1: "", 2: "·", 3: "·"}.get(rank, "?")
if isinstance(rank, str):
marker = "!"
print(f"{marker} rank={rank} expected={expected}")
print(f" q: {q}")
for i, s in enumerate(slugs[:args.k], 1):
mark = " <-- expected" if s == expected else ""
print(f" {i}. {s}{mark}")
print()
if __name__ == "__main__":
main()

137
brain/schema.md Normal file
View File

@@ -0,0 +1,137 @@
# Brain Wiki Schema
This document defines the three page types in the brain wiki.
The LLM must follow this schema exactly when generating wiki pages.
## Output Format
Return a JSON array. Each element:
```json
{
"title": "exact page title",
"type": "source | concept | entity",
"subtype": "see below — omit for concept",
"domain": "see domains — omit if none fits",
"content": "Markdown body only — no frontmatter, no path"
}
```
- `subtype` for **source**: `article | pdf | book | video | note | project`
- `subtype` for **entity**: `person | company | tool | model | framework | technology`
- The pipeline computes slugs and frontmatter — never include them in output.
## Wikilink Format
All cross-references use `[[Display Name]]` — just the display name, no slug, no pipe.
Rules:
- Only link to pages in the inventory or pages you are creating in this response
- The pipeline converts `[[Display Name]]` to `[[slug|Display Name]]` automatically
- Section links must match their section type (Related Concepts → concept pages only, etc.)
Examples: `[[Domain Driven Design]]`, `[[Ryan Singer]]`, `[[Shape Up]]`
## Domains
Use one of: `ai-llm`, `software-engineering`, `product-strategy`, `finance-markets`,
`personal`, `consulting`, `climate`, `infrastructure`, `security`
---
## Source Pages — wiki/sources/<slug>.md
One page per ingested source. Books are NEVER split across multiple source pages — update the existing one.
Body sections (in this order):
### Summary
23 sentences. Core argument or finding.
### Key Claims
Bulleted list. Paraphrase — no verbatim quotes or code.
### Concepts Introduced or Reinforced
Wikilinks to concept pages ONLY. One per line.
### Entities Mentioned
Wikilinks to entity pages ONLY. One per line.
### Open Questions Raised
Gaps or follow-up questions from this source.
For books only, also add:
### Chapters
One bullet per chapter with 12 sentence summary.
### Argument Arc
Overall narrative as it becomes clear across chapters.
### Updates
Dated entries appended on re-ingestion. NEVER rewrite — only append.
---
## Concept Pages — wiki/concepts/<slug>.md
One page per idea, framework, methodology, or pattern.
Body sections (in this order):
### Definition
One-paragraph plain-language explanation.
### Why It Matters
Practical significance. Why should anyone care?
### Related Concepts
Wikilinks to concept pages ONLY.
### Related Entities
Wikilinks to entity pages ONLY.
### Sources
Wikilinks to source pages ONLY.
### Evolving Notes
Updated as new sources arrive. Append, do not rewrite.
---
## Entity Pages — wiki/entities/<slug>.md
One page per person, tool, organisation, technology, or product.
Body sections (in this order):
### Description
One-line description.
### Relevance
Why this entity matters to this knowledge base.
### Key Positions, Products, or Claims
With dates where known.
### Related Concepts
Wikilinks to concept pages ONLY.
### Related Entities
Wikilinks to entity pages ONLY.
### Sources
Wikilinks to source pages ONLY.
---
## Non-Negotiable Rules
1. Output ONLY a valid JSON array — no markdown fences, no prose before or after
2. Each element: `{"title": "...", "type": "...", "subtype": "...", "domain": "...", "content": "..."}`
3. Never include slugs, paths, or frontmatter in output — the pipeline handles these
4. Wikilinks: `[[Display Name]]` only — no pipe, no slug
5. Dates always YYYY-MM-DD (used only in content body where contextually relevant)
6. Never reproduce verbatim code — describe the pattern or technique
7. Section links must match their section type
8. One source page per book — if inventory shows it exists, include it as an UPDATE

View File

@@ -1,59 +0,0 @@
// bridge is a stdio↔HTTP adapter that lets Claude Code connect to the
// supervisor MCP server via the stdio transport.
//
// Claude Code spawns this binary as a subprocess and communicates over
// stdin/stdout. Each newline-delimited JSON-RPC message from stdin is
// forwarded to the supervisor HTTP server and the response is written back.
//
// Usage:
//
// SUPERVISOR_URL=http://localhost:3200/mcp bridge
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
url := os.Getenv("SUPERVISOR_URL")
if url == "" {
url = "http://localhost:3200/mcp"
}
client := &http.Client{}
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(line))
if err != nil {
fmt.Fprintf(os.Stderr, "bridge: build request: %v\n", err)
continue
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "bridge: request failed: %v\n", err)
continue
}
_, _ = io.Copy(os.Stdout, resp.Body)
_ = resp.Body.Close()
_, _ = os.Stdout.Write([]byte("\n"))
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "bridge: scanner: %v\n", err)
os.Exit(1)
}
}

140
cmd/hyperguild/README.md Normal file
View File

@@ -0,0 +1,140 @@
# hyperguild CLI
A small Go binary for tier probing, brain HTTP REST access, and
`.mcp.json` mode bootstrap. Replaces the supervisor's `tier` MCP and
gives shell scripts a stable interface to the brain.
## Install
```bash
task hyperguild:install
# or: go install ./cmd/hyperguild
```
The binary lands at `$(go env GOBIN)/hyperguild` (typically
`~/go/bin/hyperguild`). Make sure that's on your PATH.
## Subcommands
### `hyperguild tier`
Probes Anthropic and LiteLLM and reports the current operating tier.
```bash
$ hyperguild tier
tier 1 (full-online) managed_agents=true
$ hyperguild tier --json
{
"tier": 1,
"label": "full-online",
"available_models": null,
"managed_agents": true
}
```
Probe URLs are read from environment:
| Var | Default |
|-----------------------|-------------------------------|
| `ANTHROPIC_PROBE_URL` | `https://api.anthropic.com` |
| `LITELLM_BASE_URL` | (empty → falls through to airplane) |
### `hyperguild brain query <topic>`
BM25 search over the brain's knowledge + wiki entries.
```bash
$ hyperguild brain query "find -H symlink"
knowledge/2026-05-03-find-h-not-l-symlinked-root.md score=12 Use find -H, not find -L
...
```
Flags:
- `--limit N` — max results (default 5)
- `--json` — emit the raw response envelope
### `hyperguild brain write <type> <slug>`
Reads markdown from stdin, writes a knowledge entry.
```bash
$ cat <<EOF | hyperguild brain write knowledge example-lesson
# Example lesson
## Lesson
...
EOF
knowledge/example-lesson.md
```
### `hyperguild brain pass-rate <skill>`
Returns the pass rate for a skill over a lookback window. Computed
on-demand from `brain/sessions/*.jsonl`.
```bash
$ hyperguild brain pass-rate tdd
tdd: 47 / 50 = 94% (window: 7d)
$ hyperguild brain pass-rate tdd --window 30d --json
{
"skill": "tdd",
"window": "30d",
"pass": 142,
"fail": 8,
"skip": 5,
"total": 155,
"pass_rate": 0.9467
}
```
Flags:
- `--window` — lookback window (default `7d`; accepts `Nh`, `Nd`)
- `--json` — emit the raw response envelope
Skills with no logged invocations return zero counts and `pass_rate: null`
(indicating "no data", distinct from "always passes").
### `hyperguild mode <cloud|client-local|sovereign>`
Writes a `.mcp.json` template for the chosen operating mode.
```bash
$ hyperguild mode cloud --out ./.mcp.json
wrote ./.mcp.json (mode: cloud)
```
Flags:
- `--out PATH` — output file (default `./.mcp.json`)
- `--force` — overwrite an existing file
Modes:
- **cloud** — brain MCP only. Claude Code with no routing.
- **client-local** — brain + routing pod. The `routing` entry points at
`koala:30310/mcp` (the routing pod, deployed in Plan 6). The
`X-Hyperguild-Mode: client-local` header is forward-compat for future
modes; the pod treats absent or unknown values as `client-local`.
- **sovereign** — brain only, with a `_mode_note` explaining that this
mode primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude
Code fallback for emergency offline use.
## Environment
| Var | Default | Used by |
|-----------------------|--------------------------|---------------------|
| `BRAIN_URL` | `http://koala:30330` | `brain *`, `mode *` |
| `ANTHROPIC_PROBE_URL` | `https://api.anthropic.com` | `tier` |
| `LITELLM_BASE_URL` | (empty) | `tier` |
Override `BRAIN_URL` if your brain pod is at a different Tailscale name
or port.
## See also
- `docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md` — full spec
- `docs/superpowers/plans/2026-05-03-hyperguild-cli.md` — implementation plan

106
cmd/hyperguild/brain.go Normal file
View File

@@ -0,0 +1,106 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
)
func runBrain(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
if len(args) == 0 {
return errors.New("subcommand required (query|write|pass-rate)")
}
switch args[0] {
case "query":
return runBrainQuery(ctx, args[1:], stdin, stdout, stderr)
case "write":
return runBrainWrite(ctx, args[1:], stdin, stdout, stderr)
case "pass-rate":
return runBrainPassRate(ctx, args[1:], stdin, stdout, stderr)
default:
return fmt.Errorf("unknown subcommand: %s (expected query|write|pass-rate)", args[0])
}
}
func runBrainQuery(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("brain query", flag.ContinueOnError)
fs.SetOutput(stderr)
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
limit := fs.Int("limit", 5, "maximum number of results")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if fs.NArg() < 1 {
return errors.New("topic required")
}
topic := fs.Arg(0)
res, err := newBrainClient().Query(ctx, topic, *limit)
if err != nil {
return err
}
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(res)
}
for _, hit := range res.Results {
fmt.Fprintf(stdout, "%s score=%d %s\n", hit.Path, hit.Score, hit.Title) //nolint:errcheck
}
return nil
}
func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("brain write", flag.ContinueOnError)
fs.SetOutput(stderr)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if fs.NArg() < 2 {
return errors.New("type and slug required (e.g. brain write knowledge my-slug)")
}
kind := fs.Arg(0)
slug := fs.Arg(1)
res, err := newBrainClient().Write(ctx, kind, slug, stdin)
if err != nil {
return err
}
fmt.Fprintln(stdout, res.Path) //nolint:errcheck
return nil
}
func runBrainPassRate(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("brain pass-rate", flag.ContinueOnError)
fs.SetOutput(stderr)
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
window := fs.String("window", "7d", "lookback window (e.g. 1h, 24h, 7d, 30d)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if fs.NArg() < 1 {
return errors.New("skill required")
}
skill := fs.Arg(0)
res, err := newBrainClient().PassRate(ctx, skill, *window)
if err != nil {
return err
}
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(res)
}
if res.PassRate == nil {
fmt.Fprintf(stdout, "%s: no data (window: %s)\n", res.Skill, res.Window) //nolint:errcheck
return nil
}
fmt.Fprintf(stdout, "%s: %d / %d = %.0f%% (window: %s)\n", res.Skill, res.Pass, res.Total, *res.PassRate*100, res.Window) //nolint:errcheck
return nil
}

View File

@@ -0,0 +1,220 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func brainQueryServer(t *testing.T, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
}
func TestRunBrainQuery_Human(t *testing.T) {
srv := brainQueryServer(t, `{"results":[{"path":"knowledge/a.md","title":"A","excerpt":"...","score":9},{"path":"knowledge/b.md","title":"B","excerpt":"...","score":3}]}`)
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query", "topic"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
got := out.String()
assert.Contains(t, got, "knowledge/a.md")
assert.Contains(t, got, "score=9")
assert.Contains(t, got, "knowledge/b.md")
}
func TestRunBrainQuery_JSON(t *testing.T) {
srv := brainQueryServer(t, `{"results":[{"path":"x.md","title":"X","excerpt":"e","score":5}]}`)
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query", "--json", "topic"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), `"path": "x.md"`)
assert.Contains(t, out.String(), `"score": 5`)
}
func TestRunBrainQuery_Limit(t *testing.T) {
gotLimit := -1
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var p struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(body, &p)
gotLimit = p.Limit
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query", "--limit", "12", "topic"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Equal(t, 12, gotLimit)
}
func TestRunBrainQuery_MissingTopic(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query"}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
}
func TestRunBrain_NoSubsubcommand(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
assert.Contains(t, err.Error(), "subcommand required")
}
func TestRunBrain_UnknownSubsubcommand(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
}
func TestRunBrainWrite_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/write", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"knowledge/test-slug.md"}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(
context.Background(),
[]string{"write", "knowledge", "test-slug"},
strings.NewReader("# Test\n\nSome body content.\n"),
&out, &errBuf,
)
require.NoError(t, err)
assert.Contains(t, out.String(), "knowledge/test-slug.md")
}
func TestRunBrainWrite_MissingArgs(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf)
assert.Error(t, err)
assert.Contains(t, err.Error(), "type and slug required")
}
func TestRunBrainWrite_BackendError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("invalid slug"))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(
context.Background(),
[]string{"write", "knowledge", "bad slug"},
strings.NewReader("body"),
&out, &errBuf,
)
assert.Error(t, err)
assert.Contains(t, err.Error(), "400")
}
func TestRunBrainWrite_EmptyStdin(t *testing.T) {
gotLen := -1
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var p struct {
Content string `json:"content"`
}
_ = json.Unmarshal(body, &p)
gotLen = len(p.Content)
_, _ = w.Write([]byte(`{"path":"x.md"}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload")
}
func TestRunBrainPassRate_Human(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
got := out.String()
assert.Contains(t, got, "tdd")
assert.Contains(t, got, "47 / 50")
assert.Contains(t, got, "94%")
}
func TestRunBrainPassRate_NoData(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), "no data")
}
func TestRunBrainPassRate_JSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "--json", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), `"pass_rate": 0.94`)
}
func TestRunBrainPassRate_MissingSkill(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate"}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
assert.Contains(t, err.Error(), "skill required")
}
func TestRunBrainPassRate_WindowFlag(t *testing.T) {
gotWindow := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotWindow = r.URL.Query().Get("window")
_, _ = w.Write([]byte(`{"skill":"tdd","window":"30d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"pass-rate", "--window", "30d", "tdd"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Equal(t, "30d", gotWindow)
}

159
cmd/hyperguild/http.go Normal file
View File

@@ -0,0 +1,159 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
const defaultBrainURL = "http://koala:30330"
// brainClient calls the brain HTTP REST API exposed alongside the MCP
// endpoint at the same host:port. /mcp serves MCP framing; /query and /write
// serve plain REST. We use the REST surface because the CLI is a
// shell-friendly client; MCP framing is unnecessary.
type brainClient struct {
baseURL string
http *http.Client
}
func newBrainClient() *brainClient {
u := os.Getenv("BRAIN_URL")
if u == "" {
u = defaultBrainURL
}
return &brainClient{
baseURL: u,
http: &http.Client{Timeout: 5 * time.Second},
}
}
// QueryHit mirrors a single result from the brain's /query endpoint.
type QueryHit struct {
Path string `json:"path"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
Score int `json:"score"`
}
// QueryResult mirrors the /query response envelope.
type QueryResult struct {
Results []QueryHit `json:"results"`
}
func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*QueryResult, error) {
payload, err := json.Marshal(struct {
Query string `json:"query"`
Limit int `json:"limit"`
}{Query: topic, Limit: limit})
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
u := c.baseURL + "/query"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("brain POST /query: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("brain POST /query: status %d: %s", resp.StatusCode, string(body))
}
var out QueryResult
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode /query response: %w", err)
}
return &out, nil
}
// WriteResult mirrors the /write response envelope.
type WriteResult struct {
Path string `json:"path"`
}
func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) {
body, err := io.ReadAll(content)
if err != nil {
return nil, fmt.Errorf("read content: %w", err)
}
payload, err := json.Marshal(struct {
Type string `json:"type"`
Slug string `json:"slug"`
Content string `json:"content"`
}{Type: kind, Slug: slug, Content: string(body)})
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
u := c.baseURL + "/write"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("brain POST /write: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("brain POST /write: status %d: %s", resp.StatusCode, string(respBody))
}
var out WriteResult
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode /write response: %w", err)
}
return &out, nil
}
// PassRateResult mirrors the /pass-rate response envelope.
type PassRateResult struct {
Skill string `json:"skill"`
Window string `json:"window"`
Pass int `json:"pass"`
Fail int `json:"fail"`
Skip int `json:"skip"`
Total int `json:"total"`
PassRate *float64 `json:"pass_rate"`
}
func (c *brainClient) PassRate(ctx context.Context, skill, window string) (*PassRateResult, error) {
q := url.Values{}
q.Set("skill", skill)
q.Set("window", window)
u := c.baseURL + "/pass-rate?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("brain GET /pass-rate: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("brain GET /pass-rate: status %d: %s", resp.StatusCode, string(body))
}
var out PassRateResult
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode /pass-rate response: %w", err)
}
return &out, nil
}

131
cmd/hyperguild/http_test.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBrainClient_Query_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/query", r.URL.Path)
body, _ := io.ReadAll(r.Body)
var got struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "find-h", got.Query)
assert.Equal(t, 3, got.Limit)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"path":"knowledge/x.md","title":"x","excerpt":"...","score":7}]}`))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
res, err := c.Query(context.Background(), "find-h", 3)
require.NoError(t, err)
require.Len(t, res.Results, 1)
assert.Equal(t, "knowledge/x.md", res.Results[0].Path)
assert.Equal(t, 7, res.Results[0].Score)
}
func TestBrainClient_Query_TransportError(t *testing.T) {
c := &brainClient{baseURL: "http://127.0.0.1:1", http: http.DefaultClient}
_, err := c.Query(context.Background(), "x", 5)
assert.Error(t, err)
}
func TestBrainClient_Query_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
_, err := c.Query(context.Background(), "x", 5)
require.Error(t, err)
assert.Contains(t, err.Error(), "500")
}
func TestBrainClient_Write_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/write", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
body, _ := io.ReadAll(r.Body)
var got struct {
Type string `json:"type"`
Slug string `json:"slug"`
Content string `json:"content"`
}
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "knowledge", got.Type)
assert.Equal(t, "find-h", got.Slug)
assert.Equal(t, "# body\n", got.Content)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n"))
require.NoError(t, err)
assert.Equal(t, "knowledge/find-h.md", res.Path)
}
func TestBrainClient_PassRate_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/pass-rate", r.URL.Path)
assert.Equal(t, "tdd", r.URL.Query().Get("skill"))
assert.Equal(t, "7d", r.URL.Query().Get("window"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":47,"fail":3,"skip":0,"total":50,"pass_rate":0.94}`))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
res, err := c.PassRate(context.Background(), "tdd", "7d")
require.NoError(t, err)
assert.Equal(t, "tdd", res.Skill)
assert.Equal(t, 47, res.Pass)
assert.Equal(t, 3, res.Fail)
require.NotNil(t, res.PassRate)
assert.InDelta(t, 0.94, *res.PassRate, 0.001)
}
func TestBrainClient_PassRate_NullRate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"skill":"tdd","window":"7d","pass":0,"fail":0,"skip":0,"total":0,"pass_rate":null}`))
}))
defer srv.Close()
c := &brainClient{baseURL: srv.URL, http: srv.Client()}
res, err := c.PassRate(context.Background(), "tdd", "7d")
require.NoError(t, err)
assert.Nil(t, res.PassRate)
}
func TestNewBrainClient_DefaultURL(t *testing.T) {
t.Setenv("BRAIN_URL", "")
c := newBrainClient()
assert.Equal(t, "http://koala:30330", c.baseURL)
}
func TestNewBrainClient_OverrideURL(t *testing.T) {
t.Setenv("BRAIN_URL", "http://localhost:9999")
c := newBrainClient()
assert.Equal(t, "http://localhost:9999", c.baseURL)
}

71
cmd/hyperguild/main.go Normal file
View File

@@ -0,0 +1,71 @@
// Package main implements the hyperguild CLI: tier probe, brain HTTP REST
// access, and .mcp.json mode bootstrap. See docs/superpowers/specs/.
package main
import (
"context"
"fmt"
"io"
"os"
)
// subcommand is the contract every hyperguild subcommand satisfies.
// Functions take an explicit context, args (without the subcommand name
// itself), and explicit IO so tests can exercise full flows without
// touching os.Stdin / os.Stdout / os.Exit.
type subcommand func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error
func subcommands() map[string]subcommand {
return map[string]subcommand{
"tier": runTier,
"brain": runBrain,
"mode": runMode,
}
}
const usage = `Usage: hyperguild <subcommand> [options]
Subcommands:
tier Probe Anthropic + LiteLLM, print current operating tier.
brain query <q> BM25 search the brain (HTTP REST).
brain write <t> <s>
Write stdin as a knowledge entry of type <t>, slug <s>.
mode <name> Bootstrap .mcp.json for a chosen mode:
cloud | client-local | sovereign
Environment:
BRAIN_URL Brain HTTP REST + MCP base URL.
Default: http://koala:30330
ANTHROPIC_PROBE_URL Tier probe URL for the Anthropic API.
Default: https://api.anthropic.com
LITELLM_BASE_URL Tier probe URL for the LiteLLM gateway.
Optional; if empty, falls through to airplane tier.
`
// dispatch routes args to a subcommand and returns the process exit code.
// Split from main() so tests can drive it without process exit.
func dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, usage) //nolint:errcheck
return 2
}
switch args[0] {
case "-h", "--help", "help":
fmt.Fprint(stdout, usage) //nolint:errcheck
return 0
}
cmd, ok := subcommands()[args[0]]
if !ok {
fmt.Fprintf(stderr, "hyperguild: unknown subcommand: %s\n%s", args[0], usage) //nolint:errcheck
return 2
}
if err := cmd(ctx, args[1:], stdin, stdout, stderr); err != nil {
fmt.Fprintf(stderr, "hyperguild %s: %v\n", args[0], err) //nolint:errcheck
return 1
}
return 0
}
func main() {
os.Exit(dispatch(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}

View File

@@ -0,0 +1,45 @@
package main
import (
"bytes"
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDispatch_Help_PrintsUsageAndReturnsZero(t *testing.T) {
var out, errBuf bytes.Buffer
code := dispatch(context.Background(), []string{"--help"}, strings.NewReader(""), &out, &errBuf)
assert.Equal(t, 0, code)
assert.Contains(t, out.String(), "Usage: hyperguild")
assert.Contains(t, out.String(), "tier")
assert.Contains(t, out.String(), "brain")
assert.Contains(t, out.String(), "mode")
}
func TestDispatch_NoArgs_PrintsUsageAndReturnsTwo(t *testing.T) {
var out, errBuf bytes.Buffer
code := dispatch(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
assert.Equal(t, 2, code)
assert.Contains(t, errBuf.String(), "Usage: hyperguild")
}
func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) {
var out, errBuf bytes.Buffer
code := dispatch(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf)
assert.Equal(t, 2, code)
assert.Contains(t, errBuf.String(), "unknown subcommand: bogus")
}
func TestDispatch_KnownSubcommand_RoutesToHandler(t *testing.T) {
// "mode" without args fails → exit 1, message on stderr.
// (Confirms dispatch reached the handler rather than printing "unknown
// subcommand: mode".)
var out, errBuf bytes.Buffer
code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf)
assert.Equal(t, 1, code)
assert.Contains(t, errBuf.String(), "name required")
assert.NotContains(t, errBuf.String(), "unknown subcommand")
}

101
cmd/hyperguild/mode.go Normal file
View File

@@ -0,0 +1,101 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
)
func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("mode", flag.ContinueOnError)
fs.SetOutput(stderr)
out := fs.String("out", ".mcp.json", "output file path")
force := fs.Bool("force", false, "overwrite an existing file")
// Pull the first positional (mode name) out so flags after it still parse
// with stdlib flag (which stops at the first non-flag arg).
if len(args) < 1 {
return errors.New("name required (cloud|client-local|sovereign)")
}
name := args[0]
if err := fs.Parse(args[1:]); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
brainURL := os.Getenv("BRAIN_URL")
if brainURL == "" {
brainURL = defaultBrainURL
}
var doc map[string]any
switch name {
case "cloud":
doc = modeCloud(brainURL)
case "client-local":
doc = modeClientLocal(brainURL)
case "sovereign":
doc = modeSovereign(brainURL)
default:
return fmt.Errorf("unknown mode: %s (expected cloud|client-local|sovereign)", name)
}
if !*force {
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("%s exists (use --force to overwrite)", *out)
}
}
body, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return fmt.Errorf("marshal mode doc: %w", err)
}
if err := os.WriteFile(*out, append(body, '\n'), 0o644); err != nil {
return fmt.Errorf("write %s: %w", *out, err)
}
fmt.Fprintf(stdout, "wrote %s (mode: %s)\n", *out, name) //nolint:errcheck
return nil
}
func modeCloud(brainURL string) map[string]any {
return map[string]any{
"mcpServers": map[string]any{
"brain": map[string]any{
"url": brainURL + "/mcp",
"description": "Brain MCP — knowledge query, write, ingestion, session log",
},
},
}
}
func modeClientLocal(brainURL string) map[string]any {
return map[string]any{
"mcpServers": map[string]any{
"brain": map[string]any{
"url": brainURL + "/mcp",
"description": "Brain MCP — knowledge query, write, ingestion, session log",
},
"routing": map[string]any{
"url": "http://koala:30310/mcp",
"description": "Mode 2 routing pod — routes skill calls to LiteLLM/local",
"headers": map[string]any{
"X-Hyperguild-Mode": "client-local",
},
},
},
}
}
func modeSovereign(brainURL string) map[string]any {
return map[string]any{
"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).",
"mcpServers": map[string]any{
"brain": map[string]any{
"url": brainURL + "/mcp",
"description": "Brain MCP — knowledge query, write, ingestion, session log",
},
},
}
}

148
cmd/hyperguild/mode_test.go Normal file
View File

@@ -0,0 +1,148 @@
package main
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func readJSON(t *testing.T, path string) map[string]any {
t.Helper()
b, err := os.ReadFile(path)
require.NoError(t, err)
var out map[string]any
require.NoError(t, json.Unmarshal(b, &out))
return out
}
func TestRunMode_Cloud_Default(t *testing.T) {
dir := t.TempDir()
outPath := filepath.Join(dir, ".mcp.json")
t.Setenv("BRAIN_URL", "http://koala:30330")
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
require.NoError(t, err)
got := readJSON(t, outPath)
servers, ok := got["mcpServers"].(map[string]any)
require.True(t, ok, "mcpServers must be a JSON object")
assert.Contains(t, servers, "brain")
assert.NotContains(t, servers, "routing")
assert.NotContains(t, got, "_mode_note")
}
func TestRunMode_ClientLocal_HasRoutingEntry(t *testing.T) {
dir := t.TempDir()
outPath := filepath.Join(dir, ".mcp.json")
t.Setenv("BRAIN_URL", "http://koala:30330")
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{"client-local", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
require.NoError(t, err)
got := readJSON(t, outPath)
servers := got["mcpServers"].(map[string]any)
require.Contains(t, servers, "brain")
require.Contains(t, servers, "routing")
routing := servers["routing"].(map[string]any)
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
headers, ok := routing["headers"].(map[string]any)
require.True(t, ok, "routing entry should have headers block")
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
}
func TestModeClientLocalHasRoutingHeader(t *testing.T) {
tmp := t.TempDir() + "/mcp.json"
out := &bytes.Buffer{}
stderr := &bytes.Buffer{}
require.NoError(t, runMode(context.Background(), []string{"client-local", "--out", tmp}, nil, out, stderr))
body, err := os.ReadFile(tmp)
require.NoError(t, err)
var doc map[string]any
require.NoError(t, json.Unmarshal(body, &doc))
servers := doc["mcpServers"].(map[string]any)
routing := servers["routing"].(map[string]any)
assert.Equal(t, "http://koala:30310/mcp", routing["url"])
assert.NotContains(t, routing, "_routing_pending", "placeholder should be removed once Plan 6 ships")
headers, ok := routing["headers"].(map[string]any)
require.True(t, ok, "routing entry should have headers block")
assert.Equal(t, "client-local", headers["X-Hyperguild-Mode"])
}
func TestRunMode_Sovereign_HasModeNote(t *testing.T) {
dir := t.TempDir()
outPath := filepath.Join(dir, ".mcp.json")
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{"sovereign", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
require.NoError(t, err)
got := readJSON(t, outPath)
assert.Contains(t, got, "_mode_note")
servers := got["mcpServers"].(map[string]any)
assert.Contains(t, servers, "brain")
assert.NotContains(t, servers, "routing")
}
func TestRunMode_DefaultsOutToCwd(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir) // Go 1.24+ — replaces the older os.Chdir-with-cleanup pattern
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{"cloud"}, strings.NewReader(""), &stdout, &stderr)
require.NoError(t, err)
_, statErr := os.Stat(filepath.Join(dir, ".mcp.json"))
assert.NoError(t, statErr, ".mcp.json should exist in cwd")
}
func TestRunMode_UnknownMode(t *testing.T) {
dir := t.TempDir()
outPath := filepath.Join(dir, ".mcp.json")
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{"bogus", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown mode")
}
func TestRunMode_NoArgs(t *testing.T) {
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{}, strings.NewReader(""), &stdout, &stderr)
assert.Error(t, err)
}
func TestRunMode_RefusesToOverwrite(t *testing.T) {
dir := t.TempDir()
outPath := filepath.Join(dir, ".mcp.json")
require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644))
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr)
require.Error(t, err)
assert.Contains(t, err.Error(), "exists")
}
func TestRunMode_Force(t *testing.T) {
dir := t.TempDir()
outPath := filepath.Join(dir, ".mcp.json")
require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644))
var stdout, stderr bytes.Buffer
err := runMode(context.Background(), []string{"cloud", "--out", outPath, "--force"}, strings.NewReader(""), &stdout, &stderr)
require.NoError(t, err)
got := readJSON(t, outPath)
assert.Contains(t, got, "mcpServers")
assert.NotContains(t, got, "existing")
}

42
cmd/hyperguild/tier.go Normal file
View File

@@ -0,0 +1,42 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"github.com/mathiasbq/supervisor/internal/tier"
)
const defaultAnthropicProbe = "https://api.anthropic.com"
func runTier(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("tier", flag.ContinueOnError)
fs.SetOutput(stderr)
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
anthropicURL := os.Getenv("ANTHROPIC_PROBE_URL")
if anthropicURL == "" {
anthropicURL = defaultAnthropicProbe
}
liteLLMURL := os.Getenv("LITELLM_BASE_URL") // empty → tier falls through to airplane
info := tier.Detect(ctx, anthropicURL, liteLLMURL)
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(info); err != nil {
return fmt.Errorf("encode json: %w", err)
}
return nil
}
fmt.Fprintf(stdout, "tier %d (%s) managed_agents=%t\n", int(info.Tier), info.Label, info.ManagedAgents) //nolint:errcheck
return nil
}

View File

@@ -0,0 +1,77 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func okServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
}
func TestRunTier_Full_Human(t *testing.T) {
anthropic := okServer(t)
defer anthropic.Close()
litellm := okServer(t)
defer litellm.Close()
t.Setenv("ANTHROPIC_PROBE_URL", anthropic.URL)
t.Setenv("LITELLM_BASE_URL", litellm.URL)
var out, errBuf bytes.Buffer
err := runTier(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), "tier 1")
assert.Contains(t, out.String(), "full-online")
assert.Contains(t, out.String(), "managed_agents=true")
}
func TestRunTier_LANOnly_JSON(t *testing.T) {
litellm := okServer(t)
defer litellm.Close()
t.Setenv("ANTHROPIC_PROBE_URL", "http://127.0.0.1:1") // unreachable
t.Setenv("LITELLM_BASE_URL", litellm.URL)
var out, errBuf bytes.Buffer
err := runTier(context.Background(), []string{"--json"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
var got struct {
Tier int `json:"tier"`
Label string `json:"label"`
ManagedAgents bool `json:"managed_agents"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
assert.Equal(t, 2, got.Tier)
assert.Equal(t, "lan-only", got.Label)
assert.False(t, got.ManagedAgents)
}
func TestRunTier_Airplane_NoLiteLLMBaseURL(t *testing.T) {
t.Setenv("ANTHROPIC_PROBE_URL", "http://127.0.0.1:1")
t.Setenv("LITELLM_BASE_URL", "")
var out, errBuf bytes.Buffer
err := runTier(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), "tier 3")
assert.Contains(t, out.String(), "airplane")
}
func TestRunTier_UnknownFlag_ReturnsError(t *testing.T) {
var out, errBuf bytes.Buffer
err := runTier(context.Background(), []string{"--bogus"}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
}

170
cmd/routing/main.go Normal file
View File

@@ -0,0 +1,170 @@
package main
// The internal/skills/{debug,retrospective,review,trainer} packages imported
// below are also imported by cmd/supervisor. Plan 7 (supervisor retirement)
// MUST NOT delete these four packages — the routing pod is their second
// consumer. Plan 7 deletes only internal/skills/{tdd,spec,tier} (the skills
// that don't route to local), the supervisor binary, and supervisor manifests.
// See docs/superpowers/specs/2026-05-04-mode-2-routing-pod-design.md (Constraints).
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"github.com/mathiasbq/supervisor/internal/auth"
"github.com/mathiasbq/supervisor/internal/config"
iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/githubclient"
"github.com/mathiasbq/supervisor/internal/mcp"
"github.com/mathiasbq/supervisor/internal/mcpclient"
"github.com/mathiasbq/supervisor/internal/registry"
"github.com/mathiasbq/supervisor/internal/routing"
"github.com/mathiasbq/supervisor/internal/skills/debug"
"github.com/mathiasbq/supervisor/internal/skills/project"
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
"github.com/mathiasbq/supervisor/internal/skills/review"
"github.com/mathiasbq/supervisor/internal/skills/trainer"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
slog.SetDefault(logger)
cfg, err := config.LoadRouting()
if err != nil {
logger.Error("config load failed", "err", err)
os.Exit(1)
}
configDir := envOr("SUPERVISOR_CONFIG_DIR", "/app/config/supervisor")
mustRead := func(path string) string {
b, err := os.ReadFile(configDir + "/" + path)
if err != nil {
logger.Error("read prompt failed", "path", path, "err", err)
os.Exit(1)
}
return string(b)
}
llm := iexec.NewLiteLLM(cfg.LiteLLMBaseURL, cfg.LiteLLMAPIKey, 0)
router := &routing.Router{
Fetcher: routing.NewFetcher(cfg.BrainURL, "7d", time.Duration(cfg.PassRateTTLSeconds)*time.Second),
Logger: routing.NewLogger(cfg.BrainURL),
Policy: routing.Policy{Floor: cfg.RouteLocalFloor, Ceil: cfg.RouteLocalCeil},
FastModel: cfg.FastModel,
ThinkingModel: cfg.ThinkingModel,
Complete: llm.Complete,
}
// Skill packages call CompleteFunc(ctx, model, system, user) — no session_id
// or project_root in the signature. Rather than modifying every skill's API
// (and inflating Plan 6's blast radius), the routing pod logs every decision
// under a fixed session_id "_routing". Operators query
// `GET /pass-rate?skill=_routing&window=...` to inspect routing health.
const routingSessionID = "_routing"
wrap := func(skillName string) routing.CompleteFunc {
return func(ctx context.Context, _, system, user string) (string, int64, error) {
// The model param is ignored: the router picks the model based on policy.
return router.Run(ctx, routing.RunInput{
Skill: skillName,
System: system,
User: user,
SessionID: routingSessionID,
ProjectRoot: "",
})
}
}
reg := registry.New()
reg.Register(review.New(review.Config{
SkillPrompt: mustRead("review.md"),
DefaultModel: cfg.FastModel,
CompleteFunc: review.CompleteFunc(wrap("review")),
}))
reg.Register(debug.New(debug.Config{
SkillPrompt: mustRead("debug.md"),
DefaultModel: cfg.FastModel,
CompleteFunc: debug.CompleteFunc(wrap("debug")),
}))
reg.Register(retrospective.New(retrospective.Config{
SkillPrompt: mustRead("retrospective.md"),
DefaultModel: cfg.FastModel,
CompleteFunc: retrospective.CompleteFunc(wrap("retrospective")),
}))
reg.Register(trainer.New(trainer.Config{
ReaderPrompt: mustRead("trainer-reader.md"),
WriterPrompt: mustRead("trainer-writer.md"),
DefaultModel: cfg.FastModel,
CompleteFunc: trainer.CompleteFunc(wrap("trainer")),
}))
if cfg.GiteaMCPURL != "" {
mcpC, err := mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken)
if err != nil {
logger.Error("mcpclient init for project_create — GITEA_MCP_URL is set but GITEA_MCP_TOKEN is empty (check routing-secrets)", "err", err)
os.Exit(1)
}
var ghClient *githubclient.Client
if cfg.GitHubPAT != "" {
ghClient = githubclient.New(cfg.GitHubPAT)
}
reg.Register(project.New(project.Config{
Client: mcpC,
GitHub: ghClient,
GiteaOwner: cfg.GiteaOwner,
GitHubOwner: cfg.GitHubOwner,
GitHubPAT: cfg.GitHubPAT,
InfraRepo: cfg.InfraRepo,
}))
logger.Info("project_create registered", "gitea_mcp_url", cfg.GiteaMCPURL,
"gitea_owner", cfg.GiteaOwner, "github_owner", cfg.GitHubOwner,
"infra_repo", cfg.InfraRepo, "github_pat_set", cfg.GitHubPAT != "")
} else {
logger.Info("project_create skipped — GITEA_MCP_URL not set")
}
var validator *auth.Validator
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
audience := os.Getenv("MCP_AUDIENCE")
v, err := auth.NewValidator(dexURL, audience)
if err != nil {
logger.Error("build jwt validator", "err", err)
os.Exit(1)
}
validator = v
logger.Info("jwt auth enabled", "issuer", dexURL)
}
srv := mcp.NewServer(reg, cfg.MCPAuthToken, validator)
mux := http.NewServeMux()
mux.Handle("/mcp", srv)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
resourceURL := os.Getenv("MCP_RESOURCE_URL")
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
auth.ProtectedResourceHandler(resourceURL, dexURL))
}
addr := ":" + cfg.Port
logger.Info("routing pod starting", "addr", addr,
"fast", cfg.FastModel, "thinking", cfg.ThinkingModel,
"floor", cfg.RouteLocalFloor, "ceil", cfg.RouteLocalCeil)
if err := http.ListenAndServe(addr, mux); err != nil { //nolint:gosec
logger.Error("server stopped", "err", err)
os.Exit(1)
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

135
cmd/routing/main_test.go Normal file
View File

@@ -0,0 +1,135 @@
package main_test
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRoutingPodEndToEnd boots the binary against fake LiteLLM + brain servers,
// calls tools/list and one tools/call, and verifies the brain saw a session_log POST.
func TestRoutingPodEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("end-to-end binary boot")
}
var brainHits int
llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "stub"}}},
})
}))
defer llm.Close()
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/pass-rate":
brainHits++
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.95})
case "/mcp":
brainHits++
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
}
}))
defer brain.Close()
port := freePort(t)
addr := "127.0.0.1:" + port
baseURL := "http://" + addr
bin := buildRouting(t)
cmd := exec.Command(bin)
cmd.Env = []string{
"ROUTING_PORT=" + port,
"LITELLM_BASE_URL=" + llm.URL,
"LITELLM_API_KEY=stub",
"BRAIN_URL=" + brain.URL,
"SUPERVISOR_CONFIG_DIR=../../config/supervisor",
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
require.NoError(t, cmd.Start())
t.Cleanup(func() { _ = cmd.Process.Kill() })
require.NoError(t, waitForPort(t, addr, 30*time.Second))
resp := mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
assert.Contains(t, resp, `"review"`)
assert.Contains(t, resp, `"debug"`)
assert.Contains(t, resp, `"retrospective"`)
assert.Contains(t, resp, `"trainer"`)
resp = mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"review","arguments":{"project_root":"/tmp","files":["README.md"]}}}`)
_ = resp // shape varies by skill; we only need a 200
// Wait briefly for the async session_log to land.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && brainHits < 2 {
time.Sleep(50 * time.Millisecond)
}
assert.GreaterOrEqual(t, brainHits, 2, "expected at least one /pass-rate hit and one /mcp session_log hit")
}
func buildRouting(t *testing.T) string {
t.Helper()
bin := t.TempDir() + "/routing"
out, err := exec.Command("go", "build", "-o", bin, "github.com/mathiasbq/supervisor/cmd/routing").CombinedOutput()
require.NoError(t, err, "build failed: %s", out)
return bin
}
func waitForPort(_ *testing.T, addr string, dur time.Duration) error {
deadline := time.Now().Add(dur)
for time.Now().Before(deadline) {
c, err := http.Get("http://" + addr + "/healthz") //nolint:noctx
if err == nil {
_ = c.Body.Close()
return nil
}
conn, err := http.NewRequest(http.MethodPost, "http://"+addr+"/mcp", strings.NewReader(`{}`))
if err == nil {
r, err := http.DefaultClient.Do(conn)
if err == nil {
_ = r.Body.Close()
return nil
}
}
time.Sleep(50 * time.Millisecond)
}
return context.DeadlineExceeded
}
func mcpCall(t *testing.T, url, body string) string {
t.Helper()
r, err := http.Post(url, "application/json", strings.NewReader(body)) //nolint:noctx
require.NoError(t, err)
defer func() { _ = r.Body.Close() }()
raw, err := io.ReadAll(r.Body)
require.NoError(t, err)
return string(raw)
}
// freePort grabs an OS-assigned TCP port and releases it. There is a small
// race window before the subprocess re-binds it, but it is acceptable for
// test isolation against a hardcoded port colliding with another test or
// stray process.
func freePort(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := l.Addr().(*net.TCPAddr).Port
require.NoError(t, l.Close())
return strconv.Itoa(port)
}

View File

@@ -1,97 +0,0 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"github.com/mathiasbq/supervisor/internal/config"
iexec "github.com/mathiasbq/supervisor/internal/exec"
"github.com/mathiasbq/supervisor/internal/mcp"
"github.com/mathiasbq/supervisor/internal/registry"
"github.com/mathiasbq/supervisor/internal/skills/brain"
"github.com/mathiasbq/supervisor/internal/skills/org"
"github.com/mathiasbq/supervisor/internal/skills/retrospective"
"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
"github.com/mathiasbq/supervisor/internal/skills/tdd"
"github.com/mathiasbq/supervisor/internal/tier"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("load config", "err", err)
os.Exit(1)
}
models, err := config.LoadModels(cfg.ModelsFile)
if err != nil {
logger.Error("load models", "err", err)
os.Exit(1)
}
systemPrompt, err := os.ReadFile(cfg.ConfigDir + "/CLAUDE.md")
if err != nil {
logger.Error("read supervisor CLAUDE.md", "path", cfg.ConfigDir+"/CLAUDE.md", "err", err)
os.Exit(1)
}
tddPrompt, err := os.ReadFile(cfg.ConfigDir + "/tdd.md")
if err != nil {
logger.Error("read tdd.md", "path", cfg.ConfigDir+"/tdd.md", "err", err)
os.Exit(1)
}
retroPrompt, err := os.ReadFile(cfg.ConfigDir + "/retrospective.md")
if err != nil {
logger.Error("read retrospective.md", "path", cfg.ConfigDir+"/retrospective.md", "err", err)
os.Exit(1)
}
executor := iexec.New(iexec.Config{
SystemPrompt: string(systemPrompt),
LiteLLMBaseURL: cfg.LiteLLMBaseURL,
LiteLLMAPIKey: cfg.LiteLLMAPIKey,
})
tierFn := func(ctx context.Context) tier.Info {
return tier.Detect(ctx, "https://api.anthropic.com", cfg.LiteLLMBaseURL)
}
reg := registry.New()
reg.Register(tdd.New(tdd.Config{
SystemPrompt: string(systemPrompt),
SkillPrompt: string(tddPrompt),
DefaultModel: models.Resolve("tdd", ""),
ExecutorFn: executor.Run,
}))
reg.Register(brain.New(brain.Config{
IngestBaseURL: cfg.IngestBaseURL,
}))
reg.Register(org.New(org.Config{
TierFn: tierFn,
}))
reg.Register(sessionlog.New(sessionlog.Config{
SessionsDir: cfg.SessionsDir,
}))
reg.Register(retrospective.New(retrospective.Config{
SkillPrompt: string(retroPrompt),
DefaultModel: models.Resolve("retrospective", ""),
SessionsDir: cfg.SessionsDir,
ExecutorFn: executor.Run,
}))
srv := mcp.NewServer(reg)
mux := http.NewServeMux()
mux.Handle("/mcp", srv)
addr := ":" + cfg.Port
logger.Info("supervisor starting", "addr", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
}
}

View File

@@ -1,14 +0,0 @@
package main
import (
"os/exec"
"testing"
)
func TestBinaryCompiles(t *testing.T) {
cmd := exec.Command("go", "build", "./...")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("build failed: %s\n%s", err, out)
}
}

View File

@@ -1,11 +1,26 @@
# Model routing table — three-layer priority:
# 1. model param in MCP tool call (caller override)
# 2. per-skill entry here
# 3. default (fallback)
default: ollama/qwen3-coder-30b-tuned
# Model selection — first entry per skill is used.
# Override per-call by passing model in the MCP tool args.
# Model names come from LiteLLM /v1/models (host/name format).
default_chain:
- iguana/qwen3-coder-next
skills:
tdd: ollama/qwen3-coder-30b-tuned
review: ollama/devstral-tuned
debug: ollama/deepseek-r1-tuned
retrospective: ollama/qwen3-coder-30b-tuned
tdd:
chain:
- koala/qwen3-coder-30b
review:
chain:
- iguana/devstral
debug:
chain:
- iguana/deepseek-r1-14b
spec:
chain:
- koala/phi4-14b
retrospective:
chain:
- iguana/qwen3-coder-next
trainer:
chain:
- iguana/qwen3-coder-next

View File

@@ -0,0 +1,31 @@
# Debug Discipline
You are a systematic debugger. Form hypotheses before suggesting fixes.
## Iron laws
1. Never suggest "try X and see what happens" — every hypothesis must have a specific expected outcome if correct
2. Generate exactly 3-5 hypotheses, ordered by likelihood (most likely first)
3. Never fix the bug — diagnose only; the caller decides what to do with the hypotheses
## Output contract
Return JSON result with:
- `status`: "pass" (hypotheses generated) or "error" (error too ambiguous to analyse)
- `phase`: "debug"
- `skill`: "debug"
- `file_path`: the most relevant file to the error (read it)
- `runner_output`: your hypotheses, formatted as:
```
HYPOTHESIS 1 (likelihood: high): <mechanism>
VERIFY: <exact command or file to check> → expected if correct: <specific output>
HYPOTHESIS 2 (likelihood: medium): <mechanism>
VERIFY: <exact command or file to check> → expected if correct: <specific output>
```
- `verified`: false — verification is the caller's job
- `message`: "N hypotheses for: <one-line error summary>"
## Rules
1. Read the error and any context files provided before forming hypotheses
2. Identify the failure mode first — what actually went wrong, not just what the error says
3. For each hypothesis: name the mechanism, explain why it would produce this exact error, give a concrete verification command with expected output
4. If the error is clearly a typo or trivial mistake, still form 3 hypotheses — surface the most likely cause as #1

View File

@@ -1,27 +1,31 @@
# The Hyperguild Way
# Hyperguild Skill Protocols
These protocols are injected into every worker invocation. They define how you behave as a member of the hyperguild.
**IMPORTANT: DO NOT OUTPUT JSON. DO NOT USE JSON CODE BLOCKS.**
Your response must be plain markdown text. No `{"status":...}`, no ` ```json `, nothing.
If you output JSON you will be ignored. Respond in prose and markdown only.
## Output contract
---
Every response is raw JSON matching the response schema. No preamble, no prose, no markdown. Malformed output is treated as a failed invocation.
## Role
## Quality gate
You are a consultant. You analyse, suggest, and explain.
Claude Code has the tools to read files, run commands, and write code.
You provide the thinking; Claude Code provides the action.
`verified: true` only when a subprocess exit code confirms the outcome. Never self-assess. "I think the tests pass" is not verified.
## Output
## Escalation
Write in clear markdown. Lead with the key finding. Use headers and bullet lists
where they help. Be concise — Claude Code reads your full response.
If stuck after 3 attempts, return `status: error` with a clear `message` explaining why. Do not retry silently. Do not fabricate a passing result.
Do not make up file contents, test results, or command output you have not seen.
If you lack context to give a useful answer, say so and state what you need.
## Working offline
## Context blocks
If brain context is absent from your prompt, proceed using your discipline file only. Note the gap in your `message` field: "no brain context available".
You may receive one or both of these blocks before your task:
## Handoff format
**`## Relevant knowledge`** — patterns and decisions from past sessions. Let them
inform your approach. Do not contradict them without reason.
Structure your output so the next worker in a chain can consume it without transformation. Use the standard result schema. Do not add extra fields.
## Session logging
The Go skill handler records your invocation in the session log automatically. You do not need to do this yourself.
**`## Session history`** — what has already happened in this session. Build on it,
do not repeat it.

View File

@@ -1,40 +1,33 @@
# Retrospective Worker Discipline
# Retrospective Discipline
You are the retrospective worker. Your job is to review a completed coding session and identify knowledge worth preserving in the hyperguild brain.
You review a completed coding session and identify knowledge worth preserving.
## What you receive
- A session log in JSON format listing every skill invocation: what was attempted, what failed, what passed, how long it took.
## What you produce
For each significant learning, call brain_write with a structured markdown note. Then return a JSON result summarising what you wrote.
A session log in JSON format listing every skill invocation: what was attempted,
what failed, what passed, how long it took.
## What is worth preserving
- Patterns that worked and should be repeated
- Failures that revealed something non-obvious about the codebase or the discipline
- Failures that revealed something non-obvious about the codebase or the approach
- Decisions made during the session (architectural, structural, tooling)
- Anything that contradicts or extends what the brain already knows
- Anything that contradicts or extends established patterns
## What is NOT worth preserving
- Routine TDD cycles with no surprises
- Routine cycles with no surprises
- Single-attempt passes with no interesting context
- Mechanical operations (file moves, renames, formatting)
## Output format
Return JSON matching the standard result schema:
Respond in markdown. For each learning worth preserving:
```json
{
"status": "pass",
"phase": "retrospective",
"skill": "retrospective",
"verified": true,
"message": "wrote N entries to brain/raw/"
}
```
**Learning:** One sentence describing what was learned.
**Context:** Why this session surfaced it — what made it non-obvious.
**Recommendation:** What should be done differently or repeated going forward.
`verified` is true when you successfully called brain_write at least once and received a confirmation. If the session had nothing worth writing, return `verified: true` with `message: "no novel learnings in this session"`.
End with a summary: "N learnings worth writing to brain" or "No novel learnings in this session."
The caller will decide which learnings to write to the brain using brain_write.

View File

@@ -0,0 +1,25 @@
# Code Review Discipline
You are a disciplined code reviewer. Read files carefully before commenting.
## Iron laws — any violation is a blocking issue
1. No security vulnerabilities: command injection, SQL injection, credential exposure, path traversal, unchecked input at system boundaries
2. No silently swallowed errors — `err != nil` without wrapping or handling is always wrong
3. No missing validation at system boundaries (user input, external APIs, file reads)
## Output format
Respond in markdown. Group findings by severity:
**CRITICAL:** Issues that violate an iron law or will cause data loss / security breach.
**WARNING:** Issues that will likely cause bugs or maintenance problems.
**SUGGESTION:** Style, clarity, or optional improvements.
For each finding include the file and line number. If nothing is wrong, explain specifically which iron law checks you ran and why they passed — never rubber-stamp.
## Rules
1. Read every file listed before writing feedback
2. Check iron laws first — if any are violated, flag them before anything else
3. Then check: correctness, test coverage for new code, Go style conventions
4. Line references required for every finding
5. End with a one-line summary: "N critical, M warnings, K suggestions" or "Clean — no issues found"

37
config/supervisor/spec.md Normal file
View File

@@ -0,0 +1,37 @@
# Spec Writing Discipline
You write structured implementation specs. Nothing is left ambiguous.
## Iron laws
1. Success criteria must be measurable — "the system is fast" is banned; "p99 < 200ms under 100 RPS" is valid
2. Always include an explicit "Out of scope" section — if you don't draw the boundary, the developer will guess wrong
3. Every technical decision in the approach must have a rationale
## Output format
Write the spec as markdown using this structure:
```
# [Feature] Spec
## Problem statement
What problem does this solve? For whom? Why now?
## Success criteria
- [ ] Criterion 1 — measurable and verifiable
- [ ] Criterion 2 — measurable and verifiable
## Constraints
Non-negotiable requirements the solution must satisfy.
## Out of scope
What we are explicitly NOT doing in this iteration.
## Technical approach
Architecture decisions, key components, rationale for each choice.
## Risks
What could go wrong, and how we'd mitigate it.
```
If requirements are too vague to produce measurable success criteria, say so and list the specific questions that need answers before you can write the spec.

View File

@@ -1,26 +1,35 @@
# TDD Skill
# TDD Discipline
## Iron Law
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST.
## Red phase
## Red phase — write a failing test
- Write exactly one test. One behavior. Name must describe the behavior clearly.
- Run the test suite. Confirm the test FAILS.
- If the test passes immediately: it tests existing behavior or is vacuous.
Return status "fail" with message explaining why the test is wrong.
- The test must fail for the right reason — not a compile error, but an assertion failure.
- Do not write any implementation code in this phase.
## Green phase
Respond with:
- The test code to write (file path + content)
- The exact failure you expect to see when running it
- Why that failure confirms the test is meaningful
## Green phase — make the test pass
- Write the minimal code to make the failing test pass. Nothing more.
- YAGNI: no extra parameters, no future-proofing, no clever abstractions.
- Run the test suite. Confirm it PASSES.
- If tests fail: fix the implementation, not the test. Max 3 attempts.
## Refactor phase
Respond with:
- The implementation code to write (file path + content)
- Confirmation of which test it targets and how it satisfies the assertion
## Refactor phase — improve without changing behavior
- Improve structure, naming, or clarity only. No new behavior.
- Tests must remain green after every change.
- If tests break during refactor: revert that change, return status "fail".
Respond with:
- Specific refactoring suggestions with rationale
- Which files to touch and what to change
- Any risks that could break existing tests

View File

@@ -0,0 +1,26 @@
# Trainer Reader Discipline
You scan session logs and identify candidate learning moments worth preserving in the brain.
## What to look for
- **Patterns that worked**: the approach was clean and correct — worth reinforcing
- **Corrections**: something was first done wrong, then corrected — both sides are valuable
## Scoring (15)
- 5: novel pattern, clearly correct, generalises across projects
- 4: good pattern, correct, somewhat project-specific but still useful
- 3: correct but obvious — include only if especially clean
- 2 or below: skip
## Output format
Respond in markdown. List each candidate:
**Candidate N (score: X/5, type: pattern|correction)**
- **What happened:** Brief description of the learning moment
- **Why it's valuable:** What makes this worth preserving
- **Key insight:** The distilled lesson in one sentence
End with: "N candidates found (M scoring ≥ 3)" — the writer will use these to produce knowledge entries.

View File

@@ -0,0 +1,31 @@
# Trainer Writer Discipline
You receive candidate learning moments from the reader and write knowledge entries for the brain.
## Quality gate (apply before writing each entry)
- The lesson must be phrased so it could apply to any project, not just this one
- No project-specific paths, variable names, or identifiers
- The insight must be stated clearly enough that someone reading it cold would understand it
## Output format
For each candidate that passes the quality gate, write a knowledge entry in this format:
```
# [Topic]
## Lesson
[The key insight in 1-3 sentences]
## When it applies
[Conditions under which this pattern is relevant]
## Example
[A brief, generic example that illustrates the lesson]
```
After presenting all entries, end with a summary:
"N entries ready for brain_write" or "0 entries passed quality gate — [reason]"
The caller will write passing entries to the brain using brain_write.

241
docs/multi-model-routing.md Normal file
View File

@@ -0,0 +1,241 @@
# Multi-Model Routing for supervisor
Reference document for implementing multi-model access within the supervisor project.
Researched April 2026. Constraints: Claude Max subscription (ToS must be respected).
---
## Goal
Route tasks to specialized, cheaper, or local models during agent and skill flows — without
violating Anthropic's terms or introducing unnecessary infrastructure risk.
---
## Hard Constraints
- Claude Max subscription is in use. Anthropic's April 2026 terms **prohibit using the
subscription with third-party harnesses that spoof the Anthropic API surface**.
- `ANTHROPIC_BASE_URL` → LiteLLM workaround is explicitly out of scope.
- Claude must remain the reasoning engine. Other models are tools, not replacements.
---
## Infrastructure Available
| Machine | Role | Relevant services |
|---------|------|-------------------|
| koala | GPU inference | llama-swap, Ollama, Qdrant, LiteLLM proxy |
| iguana | Services, builds | k3s, general services |
| flamingo | Daily driver | Claude Code runs here |
LiteLLM proxy on koala exposes 100+ models (local + cloud) through a unified API.
All machines connected via Tailscale.
---
## Approved Patterns
### Pattern 1 — Native Claude model tiering (zero build)
Claude Code subagents support per-agent model selection via frontmatter.
Use this for cost routing within the Claude model family.
```yaml
# ~/.claude/agents/explorer.md
---
name: explorer
description: File reading, code search, codebase mapping — use for all exploration tasks
model: haiku
---
```
- `haiku` for exploration, summarization, classification
- `sonnet` (default) for main reasoning and implementation
- `opus` for deep analysis, architecture decisions
**When to use**: Always. Add `model: haiku` to any subagent that does read-heavy or
classification work. Cheapest and fastest path to cost control.
---
### Pattern 2 — MCP tools wrapping local models (primary build target)
Expose local models on koala as named MCP tools. Claude remains the orchestrator and
reasoning engine — it calls local models as tools the same way it calls any other tool.
This is the intended MCP use case and carries zero ToS risk.
**Semantic contract**: Claude decides *when* to delegate based on the tool description.
Write descriptions that tell Claude what the model is good for.
#### MCP server implementation
Small Python server, run on koala or flamingo, registered in Claude Code settings.
```python
# supervisor/scripts/mcp_local_models.py
import mcp
import requests
server = mcp.Server("local-models")
LITELLM_BASE = "http://koala:4000"
OLLAMA_BASE = "http://koala:11434"
def _litellm_chat(model: str, prompt: str) -> str:
r = requests.post(f"{LITELLM_BASE}/v1/chat/completions", json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2048,
})
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
@server.tool()
def ask_local_llama(prompt: str) -> str:
"""Ask the local Llama model on koala.
Use for: bulk summarization, first-pass analysis, classification, simple Q&A,
anything that does not require deep reasoning or up-to-date knowledge.
Faster and cheaper than cloud models for routine subtasks."""
return _litellm_chat("llama3-local", prompt)
@server.tool()
def ask_coding_model(code: str, question: str) -> str:
"""Ask a code-specialized local model.
Use for: syntax checking, boilerplate generation, code formatting questions,
simple refactors where pattern-matching is sufficient."""
return _litellm_chat("codellama-local", f"Code:\n{code}\n\nQuestion: {question}")
@server.tool()
def list_available_local_models() -> list[str]:
"""List all models currently available on the local LiteLLM proxy."""
r = requests.get(f"{LITELLM_BASE}/v1/models")
r.raise_for_status()
return [m["id"] for m in r.json()["data"]]
if __name__ == "__main__":
mcp.run_stdio_server(server)
```
#### Register in Claude Code
Add to `~/.claude/settings.json` (or project-level `.claude/settings.json`):
```json
{
"mcpServers": {
"local-models": {
"command": "python3",
"args": ["/path/to/supervisor/scripts/mcp_local_models.py"]
}
}
}
```
#### LiteLLM config additions needed on koala
```yaml
# litellm config.yaml — add model entries for local models
model_list:
- model_name: llama3-local
litellm_params:
model: ollama/llama3.2
api_base: http://localhost:11434
- model_name: codellama-local
litellm_params:
model: ollama/codellama
api_base: http://localhost:11434
```
---
### Pattern 3 — External orchestration scripts (for pipeline workflows)
For multi-model pipelines that don't need to live inside a Claude Code session.
These scripts use their own API key (separate from Max subscription — API billing),
so they can call Claude API + LiteLLM freely.
Claude Code invokes them via the Bash tool.
```
Claude Code → [Bash tool] → ./scripts/orchestrate.py → {Claude API, LiteLLM, local models}
```
```python
# supervisor/scripts/orchestrate.py
import anthropic
import requests
claude = anthropic.Anthropic() # reads ANTHROPIC_API_KEY — separate from Max subscription
def analyze_document(path: str) -> str:
with open(path) as f:
content = f.read()
# Step 1: local Llama extracts structure (fast, cheap)
structure = requests.post("http://koala:4000/v1/chat/completions", json={
"model": "llama3-local",
"messages": [{"role": "user", "content": f"Extract key sections from:\n{content}"}],
}).json()["choices"][0]["message"]["content"]
# Step 2: Claude synthesizes and reasons over it
synthesis = claude.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{"role": "user", "content": f"Synthesize these findings:\n{structure}"}]
)
return synthesis.content[0].text
```
**When to use**: Batch processing, automated pipelines, workflows triggered by cron or
external events. Not for interactive Claude Code sessions.
---
## What to Skip
| Approach | Why skip |
|----------|----------|
| `ANTHROPIC_BASE_URL` → LiteLLM | ToS violation with Max subscription (April 2026 terms) |
| Third-party harnesses (OpenClaw etc.) | Explicitly banned for subscription users |
| A2A in Claude Code | Not implemented by Anthropic yet — revisit late 2026 |
| OpenAI agent handoffs | Loses execution context, not worth the complexity |
---
## Protocol Landscape (for awareness, not immediate action)
- **MCP** — production, 97M monthly downloads, your primary tool-access protocol. LiteLLM
natively supports it as both MCP gateway and MCP client as of v1.60+.
- **A2A v1.0** — Google/Linux Foundation, 150+ orgs in production, but Anthropic has not
shipped it in Claude Code. The intent is agent-to-agent peer delegation (vs MCP's
agent-to-tool). Worth watching for H2 2026.
- **AGNTCY** — Cisco/Linux Foundation, discovery and identity layer beneath MCP+A2A.
Potentially relevant for multi-machine routing across koala/iguana/flamingo once mature.
---
## Build Priority
| Step | Effort | Value | When |
|------|--------|-------|------|
| Add `model: haiku` to explorer subagents | 10 min | Immediate cost saving | Now |
| Write MCP server for local models | 23h | Local model access in sessions | Soon |
| Register MCP server in Claude Code settings | 15 min | Activates pattern 2 | With above |
| Write orchestration script template | 12h | Pipeline workflows | When needed |
---
## References
- LiteLLM MCP docs: https://docs.litellm.ai/docs/mcp
- Community MCP wrapper for LiteLLM: https://github.com/itsDarianNgo/mcp-server-litellm
- Ollama MCP server: https://github.com/rawveg/ollama-mcp
- A2A protocol status: https://www.linuxfoundation.org/press/a2a-protocol-surpasses-150-organizations-lands-in-major-cloud-platforms-and-sees-enterprise-production-use-in-first-year
- AGNTCY: https://github.com/agntcy

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,923 @@
# CD Pipeline Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a GitOps CD pipeline that automatically builds a container image on `main` push and deploys it to k3s on koala via Flux.
**Architecture:** BuildKit runs as a systemd service on koala (same host as the Gitea runner); CD pushes images to the Gitea registry and commits image tag updates to the infra repo; Flux reconciles within 60s. App secrets (including ANTHROPIC_API_KEY) are SOPS-encrypted in the infra repo and decrypted by Flux at apply time.
**Tech Stack:** Go 1.26, Node.js 22 (for claude CLI), BuildKit (buildctl), Gitea Actions, Flux (kustomize-controller), SOPS + age, k3s/containerd
---
## Environment context
This plan spans three environments. Each task header notes which environment it runs in:
- **[this-repo]** — `/Users/mathias/Documents/local-dev/AI/supervisor` on flamingo
- **[koala-ssh]** — `ssh koala` (run commands via `ssh koala "..."`)
- **[infra-repo]** — `gitea.d-ma.be/mathias/infra` (clone to a temp dir, work there, push)
- **[gitea-ui]** — Gitea web UI at `https://gitea.d-ma.be`
- **[kubectl]** — kubectl from flamingo (home LAN)
---
## File map
**This repo (supervisor):**
- Create: `Dockerfile`
- Create: `.gitea/workflows/cd.yml`
**koala host:**
- Create: `/etc/systemd/system/buildkitd.service` (or user-level equivalent)
- Create: `/root/.config/buildkit/buildkitd.toml` (registry auth config)
**Infra repo (`gitea.d-ma.be/mathias/infra`):**
- Create: `apps/supervisor/namespace.yaml`
- Create: `apps/supervisor/deployment.yaml`
- Create: `apps/supervisor/service.yaml`
- Create: `apps/supervisor/secrets.enc.yaml` (SOPS-encrypted)
- Create: `apps/supervisor/kustomization.yaml`
- Create: `apps/imagepullsecret/secret.enc.yaml` (SOPS-encrypted)
- Create: `apps/imagepullsecret/kustomization.yaml`
- Modify: `clusters/koala/kustomization.yaml` (add supervisor + imagepullsecret)
- Modify: `flux-system/kustomization.yaml` or relevant Flux Kustomization CRD (add SOPS decryption)
---
## Task 1: Dockerfile [this-repo]
The supervisor binary depends on the `claude` CLI as a subprocess. The image uses a multi-stage build: Go builder stage compiles the binary; the runtime stage is Node.js (for `npm install -g @anthropic-ai/claude-code`). Config files are baked in. The `brain/` directory is a volume mount.
**Files:**
- Create: `Dockerfile`
- [ ] **Step 1: Verify no Dockerfile exists**
```bash
ls Dockerfile 2>/dev/null || echo "confirmed: no Dockerfile"
```
Expected: `confirmed: no Dockerfile`
- [ ] **Step 2: Create the Dockerfile**
```dockerfile
# syntax=docker/dockerfile:1
# ── Build stage ───────────────────────────────────────────────────────────────
FROM golang:1.26-bookworm AS builder
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o /out/supervisor ./cmd/supervisor
# ── Runtime stage ─────────────────────────────────────────────────────────────
# Node.js 22 slim — needed for claude CLI subprocess
FROM node:22-slim
# Install claude CLI (provides the `claude` binary the supervisor shells out to)
RUN npm install -g @anthropic-ai/claude-code \
&& claude --version \
&& echo "claude CLI installed"
# Copy supervisor binary
COPY --from=builder /out/supervisor /usr/local/bin/supervisor
# Bake in config (models.yaml + skill discipline files)
COPY config/ /app/config/
WORKDIR /app
# brain/ is writable state — mount a PersistentVolume here
VOLUME /app/brain
ENV SUPERVISOR_CONFIG_DIR=/app/config/supervisor
ENV SUPERVISOR_MODELS_FILE=/app/config/models.yaml
ENV SUPERVISOR_BRAIN_DIR=/app/brain
ENV SUPERVISOR_SESSIONS_DIR=/app/brain/sessions
ENV SUPERVISOR_PORT=3200
EXPOSE 3200
ENTRYPOINT ["/usr/local/bin/supervisor"]
```
- [ ] **Step 3: Build locally to verify it compiles (no push)**
```bash
# buildctl must be available locally, OR use docker if available on flamingo
docker build --target builder -t supervisor-build-test . && echo "build stage OK"
# If no docker on flamingo, skip this step and verify at Task 3 on koala instead
```
- [ ] **Step 4: Commit**
```bash
git add Dockerfile
git commit -m "feat: add multi-stage Dockerfile with claude CLI runtime"
```
---
## Task 2: BuildKit systemd service on koala [koala-ssh]
Install `buildkitd` as a root-level systemd service on koala. The Gitea runner process runs as root (confirmed by PID/cgroup), so the root socket at `/run/buildkit/buildkitd.sock` is accessible to it.
**Files:**
- Create: `/etc/systemd/system/buildkitd.service` on koala
- Create: `/etc/buildkit/buildkitd.toml` on koala (registry auth)
- [ ] **Step 1: Check if buildkitd is already installed**
```bash
ssh koala "buildkitd --version 2>/dev/null || echo 'not installed'"
```
- [ ] **Step 2: Install buildkitd on koala**
Download the latest buildkit release binary (arm64 or amd64 — koala has x86_64):
```bash
ssh koala "
BUILDKIT_VERSION=v0.21.0
curl -sSL https://github.com/moby/buildkit/releases/download/\${BUILDKIT_VERSION}/buildkit-\${BUILDKIT_VERSION}.linux-amd64.tar.gz \
| tar -xz -C /usr/local/
buildkitd --version
"
```
Expected output includes: `buildkitd github.com/moby/buildkit v0.21.0`
- [ ] **Step 3: Create buildkitd.toml with Gitea registry auth**
The `[registry]` block configures auth for pushing to `gitea.d-ma.be`. The actual credentials come from `~/.docker/config.json` (which buildkitd reads automatically) — this toml just enables the registry:
```bash
ssh koala "
mkdir -p /etc/buildkit
cat > /etc/buildkit/buildkitd.toml << 'EOF'
[worker.containerd]
enabled = false
[worker.oci]
enabled = true
[registry.\"gitea.d-ma.be\"]
http = false
insecure = false
EOF
"
```
- [ ] **Step 4: Create systemd unit**
```bash
ssh koala "
cat > /etc/systemd/system/buildkitd.service << 'EOF'
[Unit]
Description=BuildKit daemon
After=network.target
[Service]
Type=notify
ExecStart=/usr/local/bin/buildkitd --config /etc/buildkit/buildkitd.toml
Restart=on-failure
LimitNOFILE=1048576
LimitNPROC=1048576
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable buildkitd
systemctl start buildkitd
"
```
- [ ] **Step 5: Verify the socket exists and is responsive**
```bash
ssh koala "
systemctl status buildkitd --no-pager
buildctl --addr unix:///run/buildkit/buildkitd.sock debug info
"
```
Expected: service `active (running)`, buildctl shows BuildKit version info.
- [ ] **Step 6: Smoke-test build with trivial Dockerfile**
```bash
ssh koala "
echo 'FROM alpine:3.21
RUN echo hello' | buildctl --addr unix:///run/buildkit/buildkitd.sock build \
--frontend dockerfile.v0 \
--local context=/ \
--opt filename=Dockerfile \
--output type=image,name=localhost/smoke-test:latest
echo 'smoke test OK'
"
```
Expected: `smoke test OK`
---
## Task 3: Gitea registry push auth for buildctl [koala-ssh]
`buildctl` reads Docker-style credentials from `/root/.docker/config.json`. Create the credentials file so the runner can push to `gitea.d-ma.be`.
**Prerequisites:** A Gitea user token or password with `write:packages` scope for the `mathias` org. Create one in Gitea → User Settings → Applications → Generate Token (scopes: `write:packages`).
- [ ] **Step 1: Create Gitea access token**
In Gitea UI (`https://gitea.d-ma.be`) → top-right avatar → Settings → Applications → Generate New Token.
- Token name: `buildkit-push`
- Scopes: `write:packages` (container registry write)
- Copy the token — it won't be shown again.
- [ ] **Step 2: Write docker config.json on koala**
Replace `<TOKEN>` with the token from Step 1:
```bash
ssh koala "
mkdir -p /root/.docker
TOKEN=<TOKEN>
AUTH=\$(echo -n 'mathias:'\${TOKEN} | base64)
cat > /root/.docker/config.json << EOF
{
\"auths\": {
\"gitea.d-ma.be\": {
\"auth\": \"\${AUTH}\"
}
}
}
EOF
chmod 600 /root/.docker/config.json
echo 'credentials written'
"
```
- [ ] **Step 3: Verify push works**
```bash
ssh koala "
echo 'FROM alpine:3.21' | buildctl --addr unix:///run/buildkit/buildkitd.sock build \
--frontend dockerfile.v0 \
--local context=/ \
--opt filename=Dockerfile \
--output type=image,name=gitea.d-ma.be/mathias/supervisor:push-test,push=true
echo 'push OK'
"
```
Expected: `push OK`. Verify in Gitea UI: `https://gitea.d-ma.be/mathias/supervisor/packages` should show a `push-test` tag.
- [ ] **Step 4: Delete the test image tag**
In Gitea UI → supervisor repo → Packages tab → delete the `push-test` tag.
---
## Task 4: age keypair + Flux SOPS decryption [kubectl + flamingo]
Flux decrypts SOPS-encrypted secrets at apply time. It needs the age private key stored as a k8s Secret in the `flux-system` namespace.
- [ ] **Step 1: Verify age is installed**
```bash
age --version || brew install age
```
- [ ] **Step 2: Generate age keypair**
```bash
age-keygen -o /tmp/supervisor-age.key
cat /tmp/supervisor-age.key
```
Output includes two lines:
```
# public key: age1xxxxxx...
AGE-SECRET-KEY-1xxxxxxx...
```
**Copy the public key** (the `age1...` value) — you'll need it in Task 7 for encrypting secrets.
**Store the private key file securely** — back it up outside the cluster (e.g., 1Password or encrypted note).
- [ ] **Step 3: Create the SOPS age secret in flux-system**
```bash
kubectl create secret generic sops-age \
--from-file=age.agekey=/tmp/supervisor-age.key \
-n flux-system
kubectl get secret sops-age -n flux-system
```
Expected: secret exists with `age.agekey` key.
- [ ] **Step 4: Shred the temp key file**
```bash
shred -u /tmp/supervisor-age.key
```
- [ ] **Step 5: Check what Flux Kustomization CRDs exist in the infra repo**
```bash
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra-sops-setup
ls /tmp/infra-sops-setup/flux-system/
```
Look for a `kustomization.yaml` or `gotk-sync.yaml` that defines the main Flux Kustomization resource pointing at the `clusters/koala/` path.
- [ ] **Step 6: Patch the Flux Kustomization to enable SOPS decryption**
Find the Kustomization resource that syncs `clusters/koala/`. It will look like:
```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: flux-system
namespace: flux-system
spec:
path: ./clusters/koala
...
```
Add the `decryption` block:
```yaml
decryption:
provider: sops
secretRef:
name: sops-age
```
Edit the file in `/tmp/infra-sops-setup/flux-system/` and commit:
```bash
cd /tmp/infra-sops-setup
# Edit the relevant Kustomization yaml to add decryption block (shown above)
git add flux-system/
git commit -m "feat: enable SOPS decryption via age key in flux-system"
git push
```
- [ ] **Step 7: Verify Flux picks up the change**
```bash
flux reconcile source git flux-system
flux get kustomizations
```
Expected: `flux-system` Kustomization shows `Ready True` with no errors.
- [ ] **Step 8: Clean up temp clone**
```bash
rm -rf /tmp/infra-sops-setup
```
---
## Task 5: Infra repo — supervisor app manifests [infra-repo]
Create the full k8s manifest set for the supervisor service in the infra repo. The deployment uses an `IMAGE_TAG` placeholder; the CD job patches this with the actual git sha before pushing.
**Prerequisites:** age public key from Task 4 Step 2.
- [ ] **Step 1: Clone the infra repo**
```bash
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra-supervisor
cd /tmp/infra-supervisor
```
- [ ] **Step 2: Create namespace**
```bash
mkdir -p apps/supervisor
cat > apps/supervisor/namespace.yaml << 'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: supervisor
EOF
```
- [ ] **Step 3: Create deployment**
The `brain` volume is a `hostPath` on koala (simplest for a single-node service; add a PVC later if needed). The image uses `imagePullSecrets` to pull from the Gitea registry.
```bash
cat > apps/supervisor/deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: supervisor
namespace: supervisor
spec:
replicas: 1
selector:
matchLabels:
app: supervisor
template:
metadata:
labels:
app: supervisor
spec:
nodeSelector:
kubernetes.io/hostname: koala
imagePullSecrets:
- name: gitea-registry
containers:
- name: supervisor
image: gitea.d-ma.be/mathias/supervisor:IMAGE_TAG
ports:
- containerPort: 3200
envFrom:
- secretRef:
name: supervisor-secrets
env:
- name: SUPERVISOR_PORT
value: "3200"
- name: LITELLM_BASE_URL
value: "http://iguana:4000"
- name: LLAMA_SWAP_URL
value: "http://koala:8080"
- name: INGEST_BASE_URL
value: "http://localhost:3300"
volumeMounts:
- name: brain
mountPath: /app/brain
volumes:
- name: brain
hostPath:
path: /var/lib/supervisor/brain
type: DirectoryOrCreate
EOF
```
- [ ] **Step 4: Create service**
```bash
cat > apps/supervisor/service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: supervisor
namespace: supervisor
spec:
selector:
app: supervisor
ports:
- port: 3200
targetPort: 3200
type: ClusterIP
EOF
```
- [ ] **Step 5: Create kustomization.yaml for supervisor**
```bash
cat > apps/supervisor/kustomization.yaml << 'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- secrets.enc.yaml
EOF
```
- [ ] **Step 6: Ensure clusters/koala/kustomization.yaml exists and includes supervisor**
Check if the file exists:
```bash
cat clusters/koala/kustomization.yaml 2>/dev/null || echo "need to create"
```
If it exists, add supervisor and imagepullsecret resources. If it does not exist, create it:
```bash
cat > clusters/koala/kustomization.yaml << 'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../apps/imagepullsecret
- ../../apps/supervisor
EOF
```
If it already exists, add the two resource lines (preserving existing entries).
- [ ] **Step 7: Commit (without secrets — those come in Task 6)**
```bash
cd /tmp/infra-supervisor
git add apps/supervisor/ clusters/koala/
git commit -m "feat(supervisor): add k8s manifests for supervisor service"
git push
```
---
## Task 6: SOPS-encrypted secrets in infra repo [infra-repo + flamingo]
Two encrypted secret files: the imagePullSecret for the Gitea container registry, and the supervisor app secrets (ANTHROPIC_API_KEY, LITELLM_API_KEY).
**Prerequisites:**
- age public key from Task 4 Step 2 (format: `age1xxxxx...`)
- `sops` installed (`brew install sops` if missing)
- Gitea registry token (same one used in Task 3, or create a read-only one for pulling)
- [ ] **Step 1: Verify sops is installed**
```bash
sops --version || brew install sops
```
- [ ] **Step 2: Create .sops.yaml in infra repo root**
This tells sops which key to use for all files in the repo:
```bash
cd /tmp/infra-supervisor
cat > .sops.yaml << 'EOF'
creation_rules:
- age: age1REPLACE_WITH_YOUR_PUBLIC_KEY
EOF
git add .sops.yaml
git commit -m "chore: add sops config (age key)"
git push
```
Replace `age1REPLACE_WITH_YOUR_PUBLIC_KEY` with the actual age public key from Task 4.
- [ ] **Step 3: Create and encrypt the imagePullSecret**
The imagePullSecret is a namespace-less Secret (it will be targeted per namespace via Kustomize). Create it in the `imagepullsecret` app:
```bash
mkdir -p apps/imagepullsecret
# Create a registry pull token in Gitea: Settings → Applications → Generate Token
# Scopes: read:packages
# Use that token here (or reuse the buildkit-push token — read access is enough for pulling)
PULL_TOKEN=<gitea-read-packages-token>
PULL_AUTH=$(echo -n "mathias:${PULL_TOKEN}" | base64)
cat > /tmp/gitea-pull-secret.yaml << EOF
apiVersion: v1
kind: Secret
metadata:
name: gitea-registry
namespace: supervisor
type: kubernetes.io/dockerconfigjson
stringData:
.dockerconfigjson: |
{
"auths": {
"gitea.d-ma.be": {
"auth": "${PULL_AUTH}"
}
}
}
EOF
sops --encrypt /tmp/gitea-pull-secret.yaml > apps/imagepullsecret/secret.enc.yaml
rm /tmp/gitea-pull-secret.yaml
cat > apps/imagepullsecret/kustomization.yaml << 'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- secret.enc.yaml
EOF
```
Verify the encrypted file looks correct (should show `sops:` metadata at the bottom):
```bash
tail -20 apps/imagepullsecret/secret.enc.yaml
```
- [ ] **Step 4: Create and encrypt supervisor app secrets**
```bash
# ANTHROPIC_API_KEY: your Anthropic API key
# LITELLM_API_KEY: the key your LiteLLM instance expects (can be any string if it's local)
cat > /tmp/supervisor-secrets.yaml << 'EOF'
apiVersion: v1
kind: Secret
metadata:
name: supervisor-secrets
namespace: supervisor
type: Opaque
stringData:
ANTHROPIC_API_KEY: "REPLACE_WITH_REAL_KEY"
LITELLM_API_KEY: "REPLACE_WITH_REAL_KEY"
EOF
# Edit /tmp/supervisor-secrets.yaml to insert real values, then:
sops --encrypt /tmp/supervisor-secrets.yaml > apps/supervisor/secrets.enc.yaml
rm /tmp/supervisor-secrets.yaml
```
Verify:
```bash
tail -20 apps/supervisor/secrets.enc.yaml
# Should show encrypted values and sops metadata
```
- [ ] **Step 5: Commit encrypted secrets**
```bash
cd /tmp/infra-supervisor
git add apps/imagepullsecret/ apps/supervisor/secrets.enc.yaml .sops.yaml
git commit -m "feat: add SOPS-encrypted imagePullSecret and supervisor app secrets"
git push
```
- [ ] **Step 6: Verify Flux reconciles and creates the secrets**
Wait ~60s then:
```bash
flux reconcile kustomization flux-system --with-source
kubectl get secrets -n supervisor
```
Expected: `gitea-registry` and `supervisor-secrets` appear in the `supervisor` namespace.
- [ ] **Step 7: Clean up temp clone**
```bash
rm -rf /tmp/infra-supervisor
```
---
## Task 7: Gitea org-level secrets [gitea-ui + koala-ssh]
Set the three secrets that all repos in the `mathias` org will inherit. These go in the Gitea org (not individual repos).
**Files:** No files — Gitea UI configuration.
- [ ] **Step 1: Generate SSH deploy key for infra repo**
On flamingo:
```bash
ssh-keygen -t ed25519 -C "cd-bot infra deploy key" -f /tmp/infra-deploy-key -N ""
cat /tmp/infra-deploy-key # private key → INFRA_DEPLOY_KEY secret
cat /tmp/infra-deploy-key.pub # public key → add to Gitea infra repo as deploy key
```
- [ ] **Step 2: Add public key to infra repo as a deploy key (write access)**
In Gitea UI: `https://gitea.d-ma.be/mathias/infra` → Settings → Deploy Keys → Add Deploy Key.
- Title: `cd-bot`
- Key: paste content of `/tmp/infra-deploy-key.pub`
- Enable write access: ✓
- [ ] **Step 3: Set org-level secrets in Gitea**
In Gitea UI: `https://gitea.d-ma.be/org/mathias/settings/secrets` → Add Secret.
Set these three secrets:
| Secret name | Value |
|-------------|-------|
| `INFRA_DEPLOY_KEY` | content of `/tmp/infra-deploy-key` (private key, including `-----BEGIN...` lines) |
| `BUILDKIT_REGISTRY_AUTH` | same base64 auth string as used in Task 3 Step 2 (format: `mathias:<token>` base64-encoded) |
Note: `BUILDKIT_REGISTRY_AUTH` is redundant if `/root/.docker/config.json` is already on the runner host from Task 3 — but setting it as a secret allows the `cd.yml` to explicitly pass it to `buildctl` for clarity and rotation.
- [ ] **Step 4: Clean up temp key files**
```bash
shred -u /tmp/infra-deploy-key /tmp/infra-deploy-key.pub
```
- [ ] **Step 5: Verify secrets appear in Gitea**
In Gitea UI: `https://gitea.d-ma.be/org/mathias/settings/secrets` — confirm both secrets are listed (values are hidden, only names shown).
---
## Task 8: cd.yml workflow [this-repo]
Create the CD workflow that triggers after CI passes, builds the image with buildctl, and commits the updated tag to the infra repo.
**Files:**
- Create: `.gitea/workflows/cd.yml`
- [ ] **Step 1: Create cd.yml**
```bash
cat > .gitea/workflows/cd.yml << 'EOF'
name: cd
on:
push:
branches: [main]
jobs:
deploy:
name: Build and deploy
needs: [check] # 'check' is the job name in ci.yml
runs-on: self-hosted
env:
SERVICE: supervisor
REGISTRY: gitea.d-ma.be
IMAGE: gitea.d-ma.be/mathias/supervisor
INFRA_REPO: git@gitea.d-ma.be:mathias/infra.git
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push image
run: |
IMAGE_TAG="${{ github.sha }}"
echo "Building ${IMAGE}:${IMAGE_TAG}"
buildctl --addr "${BUILDKIT_HOST}" build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--opt build-arg:VERSION="${IMAGE_TAG}" \
--output "type=image,name=${IMAGE}:${IMAGE_TAG},push=true"
echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT
id: build
- name: Update infra repo
run: |
IMAGE_TAG="${{ github.sha }}"
# Write SSH key for infra repo
mkdir -p ~/.ssh
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
chmod 600 ~/.ssh/infra_deploy_key
ssh-keyscan gitea.d-ma.be >> ~/.ssh/known_hosts 2>/dev/null
# Clone infra repo
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
git clone "${INFRA_REPO}" /tmp/infra-update
# Patch the image tag
cd /tmp/infra-update
sed -i "s|gitea.d-ma.be/mathias/supervisor:.*|gitea.d-ma.be/mathias/supervisor:${IMAGE_TAG}|" \
"apps/${SERVICE}/deployment.yaml"
# Commit and push
git config user.email "cd-bot@d-ma.be"
git config user.name "CD Bot"
git add "apps/${SERVICE}/deployment.yaml"
git commit -m "chore(deploy): ${SERVICE} → ${IMAGE_TAG}"
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
git push
# Clean up
rm -rf /tmp/infra-update
rm ~/.ssh/infra_deploy_key
echo "Infra repo updated: ${SERVICE} → ${IMAGE_TAG}"
EOF
```
- [ ] **Step 2: Verify the `needs` job name matches ci.yml**
```bash
grep "^ [a-z].*:$" .gitea/workflows/ci.yml
```
The output should show `check:` as the quality-gate job name. The `cd.yml` uses `needs: [check]` — confirm this matches.
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/cd.yml
git commit -m "feat: add CD workflow (buildctl → Gitea registry → infra repo update)"
```
---
## Task 9: End-to-end smoke test
Trigger the full pipeline and verify each stage.
- [ ] **Step 1: Push to main to trigger CI + CD**
```bash
git push origin main
```
- [ ] **Step 2: Monitor CI job in Gitea**
Open `https://gitea.d-ma.be/mathias/supervisor/actions` — wait for the `ci` workflow `check` job to pass.
- [ ] **Step 3: Monitor CD job**
In the same actions view, the `cd` workflow should start after `ci` passes. Check the `Build and push image` step output for:
```
Building gitea.d-ma.be/mathias/supervisor:<sha>
```
And the `Update infra repo` step for:
```
Infra repo updated: supervisor → <sha>
```
- [ ] **Step 4: Verify image in Gitea registry**
```
https://gitea.d-ma.be/mathias/supervisor/packages
```
Should show a new tag matching the commit sha.
- [ ] **Step 5: Verify infra repo commit**
```bash
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra-verify
cd /tmp/infra-verify
git log --oneline -3
```
Expected: most recent commit message is `chore(deploy): supervisor → <sha>`.
```bash
grep "image:" apps/supervisor/deployment.yaml
```
Expected: `image: gitea.d-ma.be/mathias/supervisor:<sha>`
- [ ] **Step 6: Verify Flux reconciles**
```bash
flux get kustomizations
```
Expected: `flux-system` shows `Ready True` and `Applied revision: main/<infra-sha>`.
```bash
kubectl get pods -n supervisor
```
Expected: supervisor pod is `Running` with the new image sha.
- [ ] **Step 7: Verify pod started correctly**
```bash
kubectl logs -n supervisor deployment/supervisor --tail=20
```
Expected: supervisor startup logs (MCP server listening on port 3200, no errors).
- [ ] **Step 8: Clean up verify clone**
```bash
rm -rf /tmp/infra-verify
```
---
## Task 10: Post-deploy — registry retention policy [gitea-ui]
Prevent the Gitea container registry from filling up by setting a tag retention policy.
- [ ] **Step 1: Set tag retention in Gitea**
In Gitea UI: `https://gitea.d-ma.be/mathias/supervisor` → Settings → Packages → Container Registry.
Set: Keep last **20** tags per image name.
If Gitea does not expose a UI retention policy, note this for manual cleanup and open a task to automate it (e.g., a weekly Actions job that calls `docker image prune` via the Gitea API).
- [ ] **Step 2: Verify existing test tags are cleaned up**
Manually delete any test tags pushed during Task 3 if not already done.
---
## Self-review checklist (for plan author — not a task)
- [x] **Spec coverage:** BuildKit systemd ✓, cd.yml ✓, Flux SOPS ✓, infra repo structure ✓, imagePullSecret ✓, app secrets ✓, Gitea org secrets ✓, error handling (implicit in workflow failures) ✓, registry retention ✓, smoke test ✓
- [x] **Placeholders:** `REPLACE_WITH_YOUR_PUBLIC_KEY` and `REPLACE_WITH_REAL_KEY` are intentional — real values come from user's secrets; marked clearly
- [x] **Type consistency:** No shared types across tasks (infra-only plan)
- [x] **Known gaps:** `needs: [check]` assumes ci.yml job name is `check` — verified in Task 8 Step 2. The `sed` image tag patch assumes no other image line in deployment.yaml — the deployment template only has one `image:` line.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,858 @@
# Brain Ingestion Quality: PDF Extraction + Entity Resolution
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix PDF ingestion (currently passes raw bytes to LLM) and add fuzzy entity resolution (prevents slug proliferation at scale).
**Architecture:** Two independent improvements wired into the existing pipeline. A new `extract` package handles text extraction by file type (pdftotext subprocess, passthrough for .md/.txt). A new `resolve.go` in the `pipeline` package normalizes proposed entity/concept titles against the loaded inventory to reuse existing slugs instead of creating duplicates. Both changes are wired into `watcher.go` and `api/handler.go` with no new dependencies except `poppler-utils` in the Docker image.
**Tech Stack:** Go stdlib (`os/exec`, `bufio`, `strings`), testify, poppler-utils (`pdftotext`)
---
## File Structure
**New files:**
- `ingestion/internal/extract/extract.go``Text(path string) (string, error)` dispatcher
- `ingestion/internal/extract/pdf.go``pdftotext` subprocess extraction
- `ingestion/internal/extract/extract_test.go` — table-driven tests for all paths
- `ingestion/internal/pipeline/resolve.go``Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []wiki.Page`
- `ingestion/internal/pipeline/resolve_test.go` — table-driven tests
**Modified files:**
- `ingestion/internal/wiki/types.go` — add `Aliases []string` to `Entry`
- `ingestion/internal/wiki/inventory.go``readFrontmatter` reads both title and aliases
- `ingestion/internal/wiki/inventory_test.go` — add alias coverage
- `ingestion/internal/pipeline/pipeline.go` — call `Resolve` after `ParsePages`
- `ingestion/internal/watcher/watcher.go` — call `extract.Text` instead of `os.ReadFile`
- `ingestion/internal/api/handler.go` — call `extract.Text` for path-based ingestion
- `ingestion/Dockerfile``apk add poppler-utils`
---
### Task 1: `extract` package — Text() dispatcher with .md/.txt passthrough
**Files:**
- Create: `ingestion/internal/extract/extract.go`
- Create: `ingestion/internal/extract/extract_test.go`
- [ ] **Step 1: Write the failing test**
```go
// ingestion/internal/extract/extract_test.go
package extract
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestText_Markdown(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
require.NoError(t, os.WriteFile(path, []byte("# Hello\n\nWorld."), 0o644))
got, err := Text(path)
require.NoError(t, err)
assert.Equal(t, "# Hello\n\nWorld.", got)
}
func TestText_Txt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
require.NoError(t, os.WriteFile(path, []byte("plain text"), 0o644))
got, err := Text(path)
require.NoError(t, err)
assert.Equal(t, "plain text", got)
}
func TestText_UnsupportedExtension(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.csv")
require.NoError(t, os.WriteFile(path, []byte("a,b,c"), 0o644))
_, err := Text(path)
assert.ErrorContains(t, err, "unsupported")
}
```
- [ ] **Step 2: Run to verify it fails**
```bash
cd ingestion && go test ./internal/extract/... -v
```
Expected: compile error — package does not exist yet.
- [ ] **Step 3: Implement extract.go**
```go
// ingestion/internal/extract/extract.go
package extract
import (
"fmt"
"os"
"strings"
)
// Text reads the file at path and returns its plain-text content.
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
func Text(path string) (string, error) {
ext := strings.ToLower(fileExt(path))
switch ext {
case ".md", ".txt":
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
return string(b), nil
case ".pdf":
return extractPDF(path)
default:
return "", fmt.Errorf("unsupported file extension: %s", ext)
}
}
// fileExt returns the file extension including the dot, lowercased.
func fileExt(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '.' {
return path[i:]
}
if path[i] == '/' || path[i] == '\\' {
break
}
}
return ""
}
```
- [ ] **Step 4: Add pdf.go stub so it compiles**
```go
// ingestion/internal/extract/pdf.go
package extract
import "fmt"
func extractPDF(_ string) (string, error) {
return "", fmt.Errorf("PDF extraction not implemented")
}
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
cd ingestion && go test ./internal/extract/... -v
```
Expected: PASS — 3 tests passing.
- [ ] **Step 6: Commit**
```bash
cd ingestion && git add internal/extract/
git commit -m "feat(extract): add Text() dispatcher with md/txt passthrough"
```
---
### Task 2: PDF extraction via pdftotext
**Files:**
- Modify: `ingestion/internal/extract/pdf.go`
- Modify: `ingestion/internal/extract/extract_test.go`
- [ ] **Step 1: Add PDF test (skip if pdftotext absent)**
Append to `extract_test.go`:
```go
func TestText_PDF(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext not available")
}
// Use a known PDF fixture; if none, create a minimal one via echo.
// The test verifies the round-trip: a PDF containing "Hello PDF" yields that string.
dir := t.TempDir()
pdfPath := filepath.Join(dir, "test.pdf")
// Generate a minimal single-page PDF using a here-doc approach.
// This is a valid minimal PDF containing the text "Hello PDF".
minimalPDF := "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>>>endobj\n" +
"4 0 obj<</Length 44>>\nstream\nBT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\nendstream\nendobj\n" +
"xref\n0 5\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\n0000000310 00000 n\n" +
"trailer<</Size 5/Root 1 0 R>>\nstartxref\n406\n%%EOF\n"
require.NoError(t, os.WriteFile(pdfPath, []byte(minimalPDF), 0o644))
got, err := Text(pdfPath)
require.NoError(t, err)
assert.Contains(t, got, "Hello PDF")
}
```
Add `"os/exec"` to imports in `extract_test.go`.
- [ ] **Step 2: Run to verify it fails (or skips)**
```bash
cd ingestion && go test ./internal/extract/... -v -run TestText_PDF
```
Expected: SKIP (pdftotext not installed locally) or FAIL with "not implemented".
- [ ] **Step 3: Implement pdf.go**
```go
// ingestion/internal/extract/pdf.go
package extract
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// extractPDF runs pdftotext on path and returns the extracted text.
// pdftotext must be installed (package: poppler-utils on Alpine/Debian, poppler on Homebrew).
func extractPDF(path string) (string, error) {
cmd := exec.Command("pdftotext", "-q", path, "-")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return "", fmt.Errorf("pdftotext: %s", errMsg)
}
return strings.TrimSpace(stdout.String()), nil
}
```
- [ ] **Step 4: Run all extract tests**
```bash
cd ingestion && go test ./internal/extract/... -v
```
Expected: PASS (PDF test skips if pdftotext absent, passes if present).
- [ ] **Step 5: Commit**
```bash
cd ingestion && git add internal/extract/pdf.go internal/extract/extract_test.go
git commit -m "feat(extract): implement PDF extraction via pdftotext"
```
---
### Task 3: `Entry.Aliases` + inventory reads aliases from frontmatter
**Files:**
- Modify: `ingestion/internal/wiki/types.go`
- Modify: `ingestion/internal/wiki/inventory.go`
- Modify: `ingestion/internal/wiki/inventory_test.go`
- [ ] **Step 1: Write failing test for alias loading**
Add to `inventory_test.go`:
```go
func TestLoadInventory_ReadsAliases(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "entities"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "sources"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(dir, "wiki", "entities", "ryan-singer.md"),
[]byte("---\ntitle: Ryan Singer\naliases:\n - Singer\n - R. Singer\n---\n\n## Description\n\nDesigner.\n"),
0o644,
))
inv, err := LoadInventory(dir)
require.NoError(t, err)
require.Len(t, inv[PageTypeEntity], 1)
e := inv[PageTypeEntity][0]
assert.Equal(t, "Ryan Singer", e.Title)
assert.Equal(t, []string{"Singer", "R. Singer"}, e.Aliases)
}
```
- [ ] **Step 2: Run to verify it fails**
```bash
cd ingestion && go test ./internal/wiki/... -v -run TestLoadInventory_ReadsAliases
```
Expected: compile error — `Entry` has no `Aliases` field.
- [ ] **Step 3: Add Aliases to Entry in types.go**
```go
// Entry is a summary of an existing wiki page used to build the inventory.
type Entry struct {
Slug string
Title string
Aliases []string
Type PageType
}
```
- [ ] **Step 4: Replace readTitle with readFrontmatter in inventory.go**
Replace the `readTitle` function and its call site:
```go
// readFrontmatter extracts title and aliases from YAML frontmatter.
// Falls back to slug for title and empty aliases on any error.
func readFrontmatter(path, fallbackSlug string) (title string, aliases []string) {
title = fallbackSlug
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
inFM := false
inAliases := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "---" {
if !inFM {
inFM = true
continue
}
break // end of frontmatter
}
if !inFM {
continue
}
// Detect alias list items (lines starting with " - ").
if inAliases {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- ") {
aliases = append(aliases, strings.TrimPrefix(trimmed, "- "))
continue
}
inAliases = false // end of alias block
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "title":
title = strings.Trim(strings.TrimSpace(val), `"'`)
case "aliases":
inAliases = true
}
}
return
}
```
Update `LoadInventory` to use `readFrontmatter`:
```go
title, aliases := readFrontmatter(path, slug)
result[pt] = append(result[pt], Entry{Slug: slug, Title: title, Aliases: aliases, Type: pt})
```
Remove the old `readTitle` function entirely.
- [ ] **Step 5: Run all wiki tests**
```bash
cd ingestion && go test ./internal/wiki/... -v
```
Expected: PASS — all existing tests plus new alias test.
- [ ] **Step 6: Commit**
```bash
cd ingestion && git add internal/wiki/types.go internal/wiki/inventory.go internal/wiki/inventory_test.go
git commit -m "feat(wiki): add Aliases to Entry and read from YAML frontmatter"
```
---
### Task 4: Fuzzy entity resolution
**Files:**
- Create: `ingestion/internal/pipeline/resolve.go`
- Create: `ingestion/internal/pipeline/resolve_test.go`
- [ ] **Step 1: Write failing tests**
```go
// ingestion/internal/pipeline/resolve_test.go
package pipeline
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
func TestResolve_NoMatch(t *testing.T) {
proposed := []wiki.Page{
{Path: "wiki/entities/new-person.md", Content: "---\ntitle: New Person\n---\n"},
}
inventory := map[wiki.PageType][]wiki.Entry{
wiki.PageTypeEntity: {
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: []string{"Singer"}},
},
}
got := Resolve(proposed, inventory)
assert.Len(t, got, 1)
assert.Equal(t, "wiki/entities/new-person.md", got[0].Path)
}
func TestResolve_TitleMatchRedirectsSlug(t *testing.T) {
// Proposed slug differs from existing but title matches.
proposed := []wiki.Page{
{Path: "wiki/entities/ryan-singer-the-designer.md", Content: "---\ntitle: Ryan Singer\n---\n"},
}
inventory := map[wiki.PageType][]wiki.Entry{
wiki.PageTypeEntity: {
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: nil},
},
}
got := Resolve(proposed, inventory)
assert.Len(t, got, 1)
assert.Equal(t, "wiki/entities/ryan-singer.md", got[0].Path)
}
func TestResolve_AliasMatchRedirectsSlug(t *testing.T) {
// Proposed title matches an existing alias.
proposed := []wiki.Page{
{Path: "wiki/entities/singer.md", Content: "---\ntitle: Singer\n---\n"},
}
inventory := map[wiki.PageType][]wiki.Entry{
wiki.PageTypeEntity: {
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: []string{"Singer", "R. Singer"}},
},
}
got := Resolve(proposed, inventory)
assert.Len(t, got, 1)
assert.Equal(t, "wiki/entities/ryan-singer.md", got[0].Path)
}
func TestResolve_NormalizationCaseAndArticles(t *testing.T) {
// "the shape up method" normalizes to "shape up method" which matches "Shape Up Method".
proposed := []wiki.Page{
{Path: "wiki/concepts/the-shape-up-method.md", Content: "---\ntitle: The Shape Up Method\n---\n"},
}
inventory := map[wiki.PageType][]wiki.Entry{
wiki.PageTypeConcept: {
{Slug: "shape-up-method", Title: "Shape Up Method", Aliases: nil},
},
}
got := Resolve(proposed, inventory)
assert.Len(t, got, 1)
assert.Equal(t, "wiki/concepts/shape-up-method.md", got[0].Path)
}
func TestResolve_OnlyMatchesSamePageType(t *testing.T) {
// A concept slug must not redirect to an entity with the same normalized name.
proposed := []wiki.Page{
{Path: "wiki/concepts/ryan-singer.md", Content: "---\ntitle: Ryan Singer\n---\n"},
}
inventory := map[wiki.PageType][]wiki.Entry{
wiki.PageTypeEntity: {
{Slug: "ryan-singer", Title: "Ryan Singer", Aliases: nil},
},
wiki.PageTypeConcept: {},
}
got := Resolve(proposed, inventory)
assert.Len(t, got, 1)
// Not redirected — different page type.
assert.Equal(t, "wiki/concepts/ryan-singer.md", got[0].Path)
}
func TestResolve_EmptyInventory(t *testing.T) {
proposed := []wiki.Page{
{Path: "wiki/entities/first.md", Content: "---\ntitle: First\n---\n"},
}
inventory := map[wiki.PageType][]wiki.Entry{}
got := Resolve(proposed, inventory)
assert.Equal(t, proposed, got)
}
```
- [ ] **Step 2: Run to verify it fails**
```bash
cd ingestion && go test ./internal/pipeline/... -v -run TestResolve
```
Expected: compile error — `Resolve` not defined.
- [ ] **Step 3: Implement resolve.go**
```go
// ingestion/internal/pipeline/resolve.go
package pipeline
import (
"path/filepath"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
// Resolve remaps proposed pages to existing slugs when a fuzzy title match is found.
// It only matches within the same page type (entities→entities, concepts→concepts).
// Pages with no inventory match are returned unchanged.
func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []wiki.Page {
// Build normalized lookup: normalized_title → canonical slug, keyed by page type.
type key struct {
pt wiki.PageType
normalized string
}
lookup := make(map[key]string) // key → canonical slug
for pt, entries := range inventory {
for _, e := range entries {
k := key{pt: pt, normalized: normalizeTitle(e.Title)}
lookup[k] = e.Slug
for _, alias := range e.Aliases {
ak := key{pt: pt, normalized: normalizeTitle(alias)}
if _, exists := lookup[ak]; !exists {
lookup[ak] = e.Slug
}
}
}
}
out := make([]wiki.Page, 0, len(proposed))
for _, page := range proposed {
pt := pageTypeFromPath(page.Path)
title := extractTitle(page.Content)
k := key{pt: pt, normalized: normalizeTitle(title)}
if canonicalSlug, ok := lookup[k]; ok {
// Redirect path to canonical slug.
dir := filepath.Dir(page.Path)
page.Path = dir + "/" + canonicalSlug + ".md"
}
out = append(out, page)
}
return out
}
// normalizeTitle lowercases, removes leading articles, collapses whitespace.
// "The Shape Up Method" → "shape up method"
func normalizeTitle(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// Strip leading articles.
for _, article := range []string{"the ", "a ", "an "} {
s = strings.TrimPrefix(s, article)
}
// Collapse internal whitespace and replace hyphens.
s = strings.ReplaceAll(s, "-", " ")
return strings.Join(strings.Fields(s), " ")
}
// pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md".
func pageTypeFromPath(path string) wiki.PageType {
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) >= 2 {
return wiki.PageType(parts[1])
}
return ""
}
// extractTitle reads the title field from YAML frontmatter in content.
// Falls back to empty string if not found.
func extractTitle(content string) string {
lines := strings.SplitN(content, "\n", 30)
inFM := false
for _, line := range lines {
if strings.TrimSpace(line) == "---" {
if !inFM {
inFM = true
continue
}
break
}
if inFM {
key, val, ok := strings.Cut(line, ":")
if ok && strings.TrimSpace(key) == "title" {
return strings.Trim(strings.TrimSpace(val), `"'`)
}
}
}
return ""
}
```
- [ ] **Step 4: Run resolve tests**
```bash
cd ingestion && go test ./internal/pipeline/... -v -run TestResolve
```
Expected: PASS — 6 tests passing.
- [ ] **Step 5: Commit**
```bash
cd ingestion && git add internal/pipeline/resolve.go internal/pipeline/resolve_test.go
git commit -m "feat(pipeline): add fuzzy entity resolution to prevent slug proliferation"
```
---
### Task 5: Wire Resolve into pipeline.Run
**Files:**
- Modify: `ingestion/internal/pipeline/pipeline.go`
- [ ] **Step 1: Add Resolve call after ParsePages in Run()**
In `pipeline.go`, locate the loop that builds `allPages`. After `allPages = append(allPages, pages...)`, we have all pages from all chunks. Resolve must run after all chunks are merged, against the snapshot inventory loaded at the start of the run.
Replace the `merged := mergeAll(allPages)` line with:
```go
resolved := Resolve(allPages, inventory)
merged := mergeAll(resolved)
```
The full relevant section of `Run` after this change:
```go
for _, chunk := range chunks {
userPrompt := BuildPrompt(schema, source, chunk, inventory)
output, err := cfg.Complete(ctx, systemPrompt, userPrompt)
if err != nil {
return Result{}, fmt.Errorf("LLM call: %w", err)
}
pages, warnings := ParsePages(output)
allPages = append(allPages, pages...)
allWarnings = append(allWarnings, warnings...)
}
resolved := Resolve(allPages, inventory)
merged := mergeAll(resolved)
```
- [ ] **Step 2: Run all pipeline tests**
```bash
cd ingestion && go test ./internal/pipeline/... -v
```
Expected: PASS — all existing tests still pass (Resolve is a no-op when inventory is empty or no title matches).
- [ ] **Step 3: Commit**
```bash
cd ingestion && git add internal/pipeline/pipeline.go
git commit -m "feat(pipeline): resolve proposed pages against inventory before writing"
```
---
### Task 6: Wire extract.Text into watcher and handler
**Files:**
- Modify: `ingestion/internal/watcher/watcher.go`
- Modify: `ingestion/internal/api/handler.go`
- [ ] **Step 1: Update watcher.go**
In `processFile`, replace:
```go
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
_, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, string(content), source, false)
```
With:
```go
content, err := extract.Text(path)
if err != nil {
return fmt.Errorf("extract text: %w", err)
}
_, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, content, source, false)
```
Add import: `"github.com/mathiasbq/hyperguild/ingestion/internal/extract"`
Remove import: `"os"` if no longer used (check — `os` is still used for `os.MkdirAll`, `os.WriteFile`, `os.Stat`; keep it).
- [ ] **Step 2: Update handler.go — single-file path**
In `IngestPath`, the single-file branch reads:
```go
content, readErr := os.ReadFile(req.Path)
if readErr != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("read file: %v", readErr))
return
}
```
Replace with:
```go
content, readErr := extract.Text(req.Path)
if readErr != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
return
}
```
- [ ] **Step 3: Update handler.go — directory walk branch**
In `IngestPath`, the directory walk reads:
```go
content, readErr := os.ReadFile(path)
if readErr != nil {
allWarnings = append(allWarnings, fmt.Sprintf("read %s: %v", path, readErr))
return nil
}
source := req.Source
if source == "" {
source = filepath.Base(path)
}
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, string(content), source, req.DryRun)
```
Replace with:
```go
content, readErr := extract.Text(path)
if readErr != nil {
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
return nil
}
source := req.Source
if source == "" {
source = filepath.Base(path)
}
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, content, source, req.DryRun)
```
Add import: `"github.com/mathiasbq/hyperguild/ingestion/internal/extract"` to handler.go.
- [ ] **Step 4: Build to verify no compile errors**
```bash
cd ingestion && go build ./...
```
Expected: success, no errors.
- [ ] **Step 5: Run all tests**
```bash
cd ingestion && go test ./...
```
Expected: PASS — all tests pass (watcher tests use .md files, already covered by extract passthrough).
- [ ] **Step 6: Commit**
```bash
cd ingestion && git add internal/watcher/watcher.go internal/api/handler.go
git commit -m "feat(watcher,api): use extract.Text() for file reading — fixes PDF ingestion"
```
---
### Task 7: Add poppler-utils to Dockerfile
**Files:**
- Modify: `ingestion/Dockerfile`
- [ ] **Step 1: Add apk install for poppler-utils**
In `ingestion/Dockerfile`, add `poppler-utils` to the Alpine runtime stage. The current final stage is:
```dockerfile
FROM alpine:3.21
COPY --from=builder /out/ingestion /usr/local/bin/ingestion
RUN addgroup -S ingestion && adduser -S -G ingestion ingestion
```
Replace with:
```dockerfile
FROM alpine:3.21
RUN apk add --no-cache poppler-utils
COPY --from=builder /out/ingestion /usr/local/bin/ingestion
RUN addgroup -S ingestion && adduser -S -G ingestion ingestion
```
- [ ] **Step 2: Verify Dockerfile builds (local Docker)**
```bash
cd ingestion && docker build -t ingestion:test .
```
Expected: image builds successfully; `pdftotext` is available inside.
- [ ] **Step 3: Verify pdftotext is accessible in the image**
```bash
docker run --rm ingestion:test pdftotext -v
```
Expected: prints version string like `pdftotext version 24.x.x`.
- [ ] **Step 4: Commit**
```bash
cd ingestion && git add Dockerfile
git commit -m "chore(docker): add poppler-utils for PDF text extraction"
```
---
## Self-Review
**Spec coverage check:**
| Requirement | Task |
|---|---|
| PDF extraction via pdftotext | Tasks 2, 6, 7 |
| .md and .txt passthrough (no regression) | Task 1 |
| Unsupported extension → clear error | Task 1 |
| Entry.Aliases loaded from frontmatter | Task 3 |
| Fuzzy normalization (case, articles, hyphens) | Task 4 |
| Alias matching | Task 4 |
| Title matching across different proposed slugs | Task 4 |
| Cross-page-type isolation (concept ≠ entity) | Task 4 |
| Resolve wired into pipeline.Run | Task 5 |
| extract.Text wired into watcher | Task 6 |
| extract.Text wired into handler (single + dir) | Task 6 |
| Dockerfile includes poppler-utils | Task 7 |
**Placeholder scan:** None found.
**Type consistency:**
- `Resolve([]wiki.Page, map[wiki.PageType][]wiki.Entry) []wiki.Page` — consistent across Tasks 4 and 5.
- `extract.Text(path string) (string, error)` — consistent across Tasks 1, 2, and 6.
- `Entry.Aliases []string` — added in Task 3, used by Resolve in Task 4 (reads `e.Aliases`).
- `readFrontmatter` replaces `readTitle` entirely in Task 3 — no lingering `readTitle` calls.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,433 @@
# Source Back-References Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** After the LLM produces wiki pages for an ingestion, automatically inject a `## Sources` back-reference on every concept and entity page that the source page links to.
**Architecture:** A new `injectSourceRefs` post-processing step is inserted between `Resolve` and `mergeAll` in `pipeline.Run`. It finds the source page in the proposed batch, extracts all `[[slug|...]]` wikilinks, then calls `wiki.Merge` with a minimal patch page to add the back-reference. `wiki.Merge` already treats `## Sources` as a bullet section with deduplication — no custom section parsing is needed. For concepts/entities that exist on disk but weren't proposed in the current batch (the common case on re-ingestion), the function loads them from disk and adds them to the pages list so they are updated.
**Tech Stack:** Go stdlib (`regexp`, `os`, `path/filepath`, `strings`), existing `wiki.Merge` and `wiki.Page` types.
---
## File Structure
**New files:**
- `ingestion/internal/pipeline/refs.go``injectSourceRefs`, `addSourceRef`, `extractWikilinks`, `findSourcePage`, `findInInventory`
- `ingestion/internal/pipeline/refs_test.go` — table-driven tests
**Modified files:**
- `ingestion/internal/pipeline/pipeline.go` — insert `injectSourceRefs` call between `Resolve` and `mergeAll`
---
### Task 1: `refs.go` — source back-reference injection
**Files:**
- Create: `ingestion/internal/pipeline/refs_test.go`
- Create: `ingestion/internal/pipeline/refs.go`
- [ ] **Step 1: Write the failing tests**
```go
// ingestion/internal/pipeline/refs_test.go
package pipeline
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
// makeInventory builds a minimal inventory for test use.
func makeInventory(concepts, entities []string) map[wiki.PageType][]wiki.Entry {
inv := map[wiki.PageType][]wiki.Entry{
wiki.PageTypeConcept: {},
wiki.PageTypeEntity: {},
wiki.PageTypeSource: {},
}
for _, slug := range concepts {
inv[wiki.PageTypeConcept] = append(inv[wiki.PageTypeConcept], wiki.Entry{Slug: slug, Title: slug})
}
for _, slug := range entities {
inv[wiki.PageTypeEntity] = append(inv[wiki.PageTypeEntity], wiki.Entry{Slug: slug, Title: slug})
}
return inv
}
func TestInjectSourceRefs_NoSourcePage(t *testing.T) {
pages := []wiki.Page{
{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nFoo.\n"},
}
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
assert.Equal(t, pages, got)
}
func TestInjectSourceRefs_InjectsIntoProposedConcept(t *testing.T) {
pages := []wiki.Page{
{
Path: "wiki/sources/my-article.md",
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSee [[domain-driven-design|Domain Driven Design]].\n",
},
{
Path: "wiki/concepts/domain-driven-design.md",
Content: "---\ntitle: Domain Driven Design\n---\n\n## Definition\n\nA methodology.\n",
},
}
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
require.Len(t, got, 2)
assert.Contains(t, got[1].Content, "## Sources")
assert.Contains(t, got[1].Content, "[[my-article|My Article]]")
}
func TestInjectSourceRefs_LoadsConceptFromDisk(t *testing.T) {
brainDir := t.TempDir()
conceptDir := filepath.Join(brainDir, "wiki", "concepts")
require.NoError(t, os.MkdirAll(conceptDir, 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(conceptDir, "shape-up.md"),
[]byte("---\ntitle: Shape Up\n---\n\n## Definition\n\nA methodology.\n"),
0o644,
))
pages := []wiki.Page{
{
Path: "wiki/sources/my-article.md",
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSee [[shape-up|Shape Up]].\n",
},
}
inv := makeInventory([]string{"shape-up"}, nil)
got := injectSourceRefs(pages, inv, brainDir)
// Should have loaded shape-up.md from disk and added it with source ref.
require.Len(t, got, 2)
var conceptPage wiki.Page
for _, p := range got {
if p.Path == "wiki/concepts/shape-up.md" {
conceptPage = p
}
}
assert.Contains(t, conceptPage.Content, "## Sources")
assert.Contains(t, conceptPage.Content, "[[my-article|My Article]]")
// Original content preserved.
assert.Contains(t, conceptPage.Content, "## Definition")
}
func TestInjectSourceRefs_NoSelfReference(t *testing.T) {
pages := []wiki.Page{
{
Path: "wiki/sources/my-article.md",
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSelf-link [[my-article|My Article]].\n",
},
}
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
// Only one page — source should not reference itself.
assert.Len(t, got, 1)
}
func TestInjectSourceRefs_DeduplicatesOnReingestion(t *testing.T) {
// Concept already has source ref from a prior ingestion.
pages := []wiki.Page{
{
Path: "wiki/sources/my-article.md",
Content: "---\ntitle: My Article\n---\n\n## Summary\n\nSee [[ddd|DDD]].\n",
},
{
Path: "wiki/concepts/ddd.md",
Content: "---\ntitle: DDD\n---\n\n## Definition\n\nA thing.\n\n## Sources\n\n- [[my-article|My Article]]\n",
},
}
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
require.Len(t, got, 2)
// The source ref must appear exactly once.
count := 0
for _, line := range splitLines(got[1].Content) {
if line == "- [[my-article|My Article]]" {
count++
}
}
assert.Equal(t, 1, count, "source ref should appear exactly once")
}
func TestInjectSourceRefs_InjectsIntoEntity(t *testing.T) {
pages := []wiki.Page{
{
Path: "wiki/sources/book.md",
Content: "---\ntitle: Book\n---\n\n## Summary\n\nBy [[ryan-singer|Ryan Singer]].\n",
},
{
Path: "wiki/entities/ryan-singer.md",
Content: "---\ntitle: Ryan Singer\n---\n\n## Description\n\nA designer.\n",
},
}
got := injectSourceRefs(pages, makeInventory(nil, nil), t.TempDir())
require.Len(t, got, 2)
var entity wiki.Page
for _, p := range got {
if p.Path == "wiki/entities/ryan-singer.md" {
entity = p
}
}
assert.Contains(t, entity.Content, "[[book|Book]]")
}
func TestExtractWikilinks(t *testing.T) {
content := "See [[foo|Foo]] and [[bar|Bar]] and [[foo|Foo again]]."
got := extractWikilinks(content)
assert.True(t, got["foo"])
assert.True(t, got["bar"])
assert.Len(t, got, 2, "duplicate slugs should be deduplicated")
}
// splitLines is a test helper.
func splitLines(s string) []string {
var out []string
for _, l := range splitNewlines(s) {
if l != "" {
out = append(out, l)
}
}
return out
}
func splitNewlines(s string) []string {
var lines []string
start := 0
for i, c := range s {
if c == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
```
- [ ] **Step 2: Run to verify they fail**
```bash
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./internal/pipeline/... -run "TestInjectSourceRefs|TestExtractWikilinks" -v
```
Expected: compile error — `injectSourceRefs` and `extractWikilinks` not defined.
- [ ] **Step 3: Implement refs.go**
```go
// ingestion/internal/pipeline/refs.go
package pipeline
import (
"os"
"path/filepath"
"regexp"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
var wikilinkRE = regexp.MustCompile(`\[\[([^|\]]+)\|`)
// injectSourceRefs finds the source page in the proposed batch, extracts its wikilinks,
// and injects a back-reference into every linked concept or entity page.
// Pages that exist on disk but are not in the current batch are loaded and appended
// so they will be updated on write.
func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entry, brainDir string) []wiki.Page {
sourceSlug, sourceTitle, found := findSourcePage(pages)
if !found {
return pages
}
// Locate source page content for wikilink extraction.
var sourceContent string
for _, p := range pages {
if strings.HasPrefix(p.Path, "wiki/sources/") &&
strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug {
sourceContent = p.Content
break
}
}
linkedSlugs := extractWikilinks(sourceContent)
sourceRef := "- [[" + sourceSlug + "|" + sourceTitle + "]]"
// Build slug → index map for proposed pages (excluding wiki/sources/).
bySlug := make(map[string]int, len(pages))
for i, p := range pages {
if !strings.HasPrefix(p.Path, "wiki/sources/") {
bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i
}
}
for slug := range linkedSlugs {
if slug == sourceSlug {
continue // no self-reference
}
if idx, ok := bySlug[slug]; ok {
// Concept/entity is in the proposed batch — inject inline.
pages[idx] = addSourceRef(pages[idx], sourceRef)
continue
}
// Not in proposed batch — look for it in the inventory (exists on disk).
pt, ok := findInInventory(slug, inventory)
if !ok {
continue
}
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md")
b, err := os.ReadFile(diskPath)
if err != nil {
continue // page not found on disk; skip
}
page := wiki.Page{
Path: "wiki/" + string(pt) + "/" + slug + ".md",
Content: string(b),
}
pages = append(pages, addSourceRef(page, sourceRef))
}
return pages
}
// addSourceRef injects sourceRef into the ## Sources bullet section of page.
// Uses wiki.Merge so that existing Sources entries are deduplicated and all
// other sections are preserved unchanged.
func addSourceRef(page wiki.Page, sourceRef string) wiki.Page {
patch := wiki.Page{
Path: page.Path,
Content: "\n## Sources\n\n" + sourceRef + "\n",
}
return wiki.Merge(page, patch)
}
// extractWikilinks returns the set of slugs referenced as [[slug|...]] in content.
func extractWikilinks(content string) map[string]bool {
slugs := make(map[string]bool)
for _, m := range wikilinkRE.FindAllStringSubmatch(content, -1) {
slugs[m[1]] = true
}
return slugs
}
// findSourcePage returns the slug and title of the first wiki/sources/ page in pages.
func findSourcePage(pages []wiki.Page) (slug, title string, found bool) {
for _, p := range pages {
if strings.HasPrefix(p.Path, "wiki/sources/") {
slug = strings.TrimSuffix(filepath.Base(p.Path), ".md")
title = extractTitle(p.Content)
if title == "" {
title = slug
}
return slug, title, true
}
}
return "", "", false
}
// findInInventory returns the PageType for a slug if it appears in the inventory.
func findInInventory(slug string, inventory map[wiki.PageType][]wiki.Entry) (wiki.PageType, bool) {
for pt, entries := range inventory {
for _, e := range entries {
if e.Slug == slug {
return pt, true
}
}
}
return "", false
}
```
- [ ] **Step 4: Run all pipeline tests**
```bash
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./internal/pipeline/... -v
```
Expected: all existing tests PASS + 7 new refs tests PASS.
- [ ] **Step 5: Commit**
```bash
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs && git add ingestion/internal/pipeline/refs.go ingestion/internal/pipeline/refs_test.go && git commit -m "feat(pipeline): inject source back-references into concept and entity pages"
```
---
### Task 2: Wire injectSourceRefs into pipeline.Run
**Files:**
- Modify: `ingestion/internal/pipeline/pipeline.go`
- [ ] **Step 1: Insert the call**
In `pipeline.go`, locate:
```go
resolved := Resolve(allPages, inventory)
merged := mergeAll(resolved)
```
Replace with:
```go
resolved := Resolve(allPages, inventory)
withRefs := injectSourceRefs(resolved, inventory, brainDir)
merged := mergeAll(withRefs)
```
No import changes needed — same package.
- [ ] **Step 2: Run all pipeline tests**
```bash
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./internal/pipeline/... -v
```
Expected: all tests PASS. The existing `TestRun_WritesPages` and `TestRun_DryRunDoesNotWrite` use LLM mocks that return source pages with no wikilinks to concepts — `injectSourceRefs` is a no-op for them.
- [ ] **Step 3: Run full test suite + lint**
```bash
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs/ingestion && go test ./... && golangci-lint run ./...
```
Expected: all packages PASS, 0 lint issues.
- [ ] **Step 4: Commit**
```bash
cd /Users/mathias/Documents/local-dev/AI/hyperguild/.worktrees/feat-source-backrefs && git add ingestion/internal/pipeline/pipeline.go && git commit -m "feat(pipeline): wire source back-reference injection into Run"
```
---
## Self-Review
**Spec coverage:**
| Requirement | Task |
|---|---|
| Concepts get `## Sources` back-link to ingested source | Task 1 |
| Entities get `## Sources` back-link | Task 1 (TestInjectSourceRefs_InjectsIntoEntity) |
| Existing pages on disk get updated with new source | Task 1 (TestInjectSourceRefs_LoadsConceptFromDisk) |
| Re-ingestion of same source does not duplicate the ref | Task 1 (TestInjectSourceRefs_DeduplicatesOnReingestion) |
| Source page does not reference itself | Task 1 (TestInjectSourceRefs_NoSelfReference) |
| No-op when batch has no source page | Task 1 (TestInjectSourceRefs_NoSourcePage) |
| Wired into Run between Resolve and mergeAll | Task 2 |
| Full test suite and lint pass | Task 2 Step 3 |
**Placeholder scan:** None.
**Type consistency:** `injectSourceRefs([]wiki.Page, map[wiki.PageType][]wiki.Entry, string) []wiki.Page` — used identically in refs.go (definition) and pipeline.go (call site).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
# CD Pipeline Design
**Date:** 2026-04-20
**Status:** Approved for implementation
## Problem statement
The supervisor (and future services on the koala k3s cluster) have no automated deployment path after CI passes. Images are not built, the cluster is updated manually, and there is no audit trail for what is running where.
## Goal
After a push to `main` passes CI, automatically build a container image, push it to the Gitea registry, and update the cluster via GitOps — with a design that scales to many repos and services without per-repo kubeconfig or secret sprawl.
## Success criteria
- [ ] Successful `main` push triggers image build and push to `gitea.d-ma.be/<org>/<repo>:<git-sha>`
- [ ] Infra repo receives a commit updating the image tag for the deployed service
- [ ] Flux reconciles within 60s of the infra repo commit; pod runs the new image
- [ ] Rollback = one commit to infra repo reverting the tag
- [ ] Secrets (app secrets, registry pull) are SOPS-encrypted in infra repo; no manual `kubectl create secret`
- [ ] Adding a new service requires only: adding `apps/<service>/` to infra repo + `cd.yml` to the app repo
- [ ] Zero changes to the k3s cluster networking or runner configuration
## Constraints
- Gitea Actions self-hosted runner runs as a **systemd host process** on koala — not a k8s pod; cannot use cluster DNS
- k3s uses containerd; no Docker daemon, no nerdctl on koala
- Flux is already running (core controllers only); image-reflector/image-automation are NOT installed and will NOT be added
- SOPS + age is the secret management standard; no plaintext Secrets in git
- All org-level Gitea secrets are shared across repos — minimize the set
## Out of scope
- Multi-cluster promotion (koala only for now; infra repo structure supports adding clusters later)
- Automated rollback on health check failure (manual rollback via infra repo commit)
- Build caching beyond BuildKit's local disk cache
- PR preview environments
---
## Architecture
```
App repo (supervisor, n8n, etc.)
↓ push to main
Gitea Actions — ci.yml (lint + test)
↓ passes
Gitea Actions — cd.yml
├─ 1. buildctl → BuildKit (unix socket on koala host)
│ → pushes gitea.d-ma.be/<org>/<repo>:<git-sha>
├─ 2. Clone infra repo (SSH deploy key)
│ → patch apps/<service>/deployment.yaml IMAGE_TAG → <git-sha>
│ → git commit + push
└─ done
gitea.d-ma.be/mathias/infra (Flux source)
↓ Flux source-controller detects new commit (30s interval)
kustomize-controller
└─ applies apps/<service>/kustomization.yaml → k3s namespace
pod runs new image (pulls from gitea.d-ma.be with imagePullSecret)
```
---
## Components
### 1. BuildKit — systemd service on koala
BuildKit runs as a rootless systemd service on the koala host, identical to the Gitea runner pattern already in use.
- Socket: `unix:///run/user/<uid>/buildkit/buildkitd.sock` (rootless) or `/run/buildkit/buildkitd.sock` (root)
- Cache: local disk at default BuildKit cache path — persists across builds
- Access: `buildctl --addr unix:///run/buildkit/buildkitd.sock` from the runner process (same host, same user)
- No k3s involvement for builds
### 2. Gitea Actions — `cd.yml`
Separate workflow file; triggers on `main` push after `ci.yml` succeeds.
```yaml
name: cd
on:
push:
branches: [main]
jobs:
deploy:
needs: [ci] # or workflow_run trigger — see implementation plan
runs-on: [self-hosted, koala]
env:
IMAGE: gitea.d-ma.be/${{ github.repository }}:${{ github.sha }}
steps:
- uses: actions/checkout@v4
- name: Build and push
run: |
buildctl --addr unix:///run/buildkit/buildkitd.sock \
build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--output type=image,name=$IMAGE,push=true
env:
BUILDKIT_HOST: unix:///run/buildkit/buildkitd.sock
- name: Update infra repo
run: |
git clone git@gitea.d-ma.be:mathias/infra.git /tmp/infra
cd /tmp/infra
sed -i "s|IMAGE_TAG|${{ github.sha }}|g" apps/${{ env.SERVICE_NAME }}/deployment.yaml
git config user.email "cd-bot@d-ma.be"
git config user.name "CD Bot"
git add apps/${{ env.SERVICE_NAME }}/deployment.yaml
git commit -m "chore(deploy): ${{ env.SERVICE_NAME }} → ${{ github.sha }}"
git push
env:
GIT_SSH_COMMAND: ssh -i /tmp/infra-deploy-key -o StrictHostKeyChecking=no
```
`SERVICE_NAME` is set per-repo (either hardcoded in `cd.yml` or derived from the repo name).
### 3. Org-level Gitea secrets
Three secrets, set once, inherited by all repos:
| Secret | Purpose |
|--------|---------|
| `BUILDKIT_REGISTRY_AUTH` | credentials for pushing to `gitea.d-ma.be` (buildctl `--opt` or `~/.docker/config.json`) |
| `INFRA_DEPLOY_KEY` | SSH private key with write access to `gitea.d-ma.be/mathias/infra` |
| `KUBECONFIG_KOALA` | (optional) kubeconfig for manual `kubectl` steps if ever needed; scoped ServiceAccount |
### 4. Infra repo structure
```
gitea.d-ma.be/mathias/infra
├── clusters/
│ └── koala/
│ └── kustomization.yaml # points at ../../apps/*/
├── apps/
│ ├── supervisor/
│ │ ├── namespace.yaml
│ │ ├── deployment.yaml # image: gitea.d-ma.be/mathias/supervisor:IMAGE_TAG
│ │ ├── service.yaml
│ │ ├── secrets.enc.yaml # SOPS-encrypted app secrets (ANTHROPIC_API_KEY, etc.)
│ │ └── kustomization.yaml
│ ├── n8n/
│ │ └── ...
│ └── imagepullsecret/
│ └── secret.enc.yaml # SOPS-encrypted imagePullSecret for gitea.d-ma.be
└── flux-system/ # existing Flux bootstrap manifests
```
Adding a new service = add `apps/<service>/` directory. The `clusters/koala/kustomization.yaml` uses a glob or explicit list.
### 5. SOPS + age for Flux
Flux decrypts SOPS-encrypted files at apply time using an age key stored as a k8s Secret in the `flux-system` namespace. Setup:
1. Generate age keypair: `age-keygen`
2. Store private key: `kubectl create secret generic sops-age --from-file=age.agekey -n flux-system`
3. Configure Flux Kustomization with `decryption.provider: sops`
4. Encrypt secrets before committing: `sops --encrypt --age <pubkey> secret.yaml > secret.enc.yaml`
App secrets (e.g., `ANTHROPIC_API_KEY`) and the registry pull secret live as encrypted files in `apps/<service>/` and `apps/imagepullsecret/` respectively.
### 6. Image pull secret
Each app namespace needs a `kubernetes.io/dockerconfigjson` Secret to pull from `gitea.d-ma.be`. This Secret is SOPS-encrypted in `apps/imagepullsecret/` and applied to each app namespace via Kustomize `namespace` field or a shared Kustomize component.
---
## Data flow: supervisor deploy
1. Push to `supervisor` main → CI passes (lint/test/vet)
2. CD job builds image: `gitea.d-ma.be/mathias/supervisor:abc1234`
3. CD job clones infra repo, patches `apps/supervisor/deployment.yaml`, commits
4. Flux source-controller detects infra commit within 30s
5. kustomize-controller applies `apps/supervisor/kustomization.yaml`
6. Flux decrypts `secrets.enc.yaml` → k8s Secret in `supervisor` namespace
7. k3s pulls `gitea.d-ma.be/mathias/supervisor:abc1234` using imagePullSecret
8. Pod starts with new image; previous pod terminates
Rollback: `git revert <tag-commit>` in infra repo → Flux reconciles → old image deployed.
---
## Error handling
| Scenario | Behaviour |
|----------|-----------|
| CI fails | `cd.yml` does not run (`needs: ci` gate) |
| BuildKit unreachable | `buildctl` exits non-zero → workflow fails; infra repo untouched |
| Image push fails | Workflow fails; infra repo untouched; cluster unchanged |
| Infra repo push conflict | Retry once with rebase; fail and alert if still conflicting |
| Flux reconcile error | Notification-controller fires alert; pods stay on previous image |
| Pod image pull fails | `ImagePullBackOff`; Flux reports degraded Kustomization |
| SOPS decrypt fails | Kustomization fails; Flux reports error; no partial apply |
---
## Testing approach
1. **BuildKit smoke test**`buildctl build` with a trivial one-line Dockerfile; verify image appears in Gitea registry
2. **cd.yml dry run** — trigger manually on a test branch; verify infra repo commit contains correct sha
3. **Flux reconcile test** — push infra commit; verify `flux get kustomizations` shows `Ready` and pod runs new image sha
4. **Pull secret test** — delete pod, verify it restarts and pulls from Gitea registry without `ImagePullBackOff`
5. **SOPS round-trip test** — encrypt a dummy secret, push to infra repo, verify Flux decrypts and `kubectl get secret` shows correct data
---
## Risks
| Risk | Mitigation |
|------|------------|
| BuildKit socket path varies by user/rootless mode | Confirm path during setup; hardcode in `cd.yml` |
| Infra repo concurrent pushes (multiple repos deploying simultaneously) | Git rebase retry handles this; unlikely at current scale |
| age private key lost | Back up to SOPS-accessible location; document recovery procedure |
| Registry storage fills up | Set Gitea registry tag retention policy (keep last 20 per repo) |
| Gitea deploy key compromised | Rotate via Gitea UI; single key for infra repo only |

View File

@@ -0,0 +1,322 @@
# Model Orchestration Design
**Date:** 2026-04-20
**Status:** Approved for implementation
## Problem statement
The hyperguild supervisor currently spawns a `claude --print` subprocess for every skill call. The model routing config (`models.yaml`) exists but is dead weight — the model name is injected as text into the task prompt and ignored. Every skill call costs Claude tokens regardless of task complexity or data sensitivity.
## Goal
Route skill work to the most appropriate model — weighing cost, latency, and quality — with Claude acting as the real supervisor: verifying outputs and deciding when to escalate. Local models on owned hardware handle the common case; Claude escalates through a chain to frontier models only when local quality is insufficient.
## Success criteria
- [ ] Each skill dispatches generation to its configured local model via LiteLLM by default
- [ ] Claude verifies every local output and either accepts or escalates
- [ ] Escalation walks a per-skill chain (local small → local large → Sonnet → Opus) with one attempt per tier
- [ ] Every attempt (model, tier, duration, warm state, verdict) is logged in the session JSONL
- [ ] Cloud tiers (Sonnet/Opus) self-certify — no separate verifier call
- [ ] Zero changes to skill handlers — they call `ExecutorFn` exactly as today
- [ ] `LiteLTMBaseURL` already in config; no new env vars required beyond `LLAMA_SWAP_URL`
## Constraints
- One attempt per tier before escalating (no retry within a tier)
- Anthropic T&C: Claude is called normally via Anthropic API; local models are called directly via LiteLLM HTTP — no API redirection
- `models.yaml` remains the single routing config file
## Out of scope
- Auto-rerouting based on real-time warm state (logged, not acted on — Phase 4)
- Multi-tenant / public service exposure
- RAG/CAG model boosting
- Managed Agent cloud delegation (chain stub only in Phase 3)
---
## Architecture
```
MCP tool call (Claude Code)
Skill handler — calls ExecutorFn (unchanged)
Orchestrator.Run (implements ExecutorFn)
├─ Resolve chain from models.yaml
├─ For each model in chain:
│ ├─ [ollama/*] → LiteLLM executor → generate
│ │ ↓
│ │ Claude verifier (task + output + discipline)
│ │ ├─ accept → return Result (log attempt)
│ │ └─ escalate → next tier (log attempt)
│ │
│ └─ [claude-*] → Claude executor (current) → generate + self-certify
│ └─ return Result (log attempt)
└─ All tiers exhausted → return best attempt with escalation note
```
Claude is always the verifier for local tiers. At cloud tiers, Claude generates and self-certifies — the verifier call is skipped.
---
## Components
### 1. `internal/exec/litellm.go` — LiteLLM executor
Calls `POST /v1/chat/completions` on the configured LiteLLM server. Implements the same `ExecutorFn` signature as the existing claude executor.
```go
type LiteLLMExecutor struct {
BaseURL string
APIKey string
HTTPClient *http.Client
Timeout time.Duration
}
func NewLiteLLM(baseURL, apiKey string, timeout time.Duration) *LiteLLMExecutor
func (e *LiteLLMExecutor) Run(ctx context.Context, req Request) (Result, error)
```
Request mapping:
- `req.SkillPrompt` → system message
- `req.TaskPrompt` → user message
- `req.Model``model` field in the chat completions request
Response handling: local models are prompted (via the discipline file output contract) to return a JSON object matching the `Result` schema. The executor attempts `json.Unmarshal` into `Result` directly — no envelope unwrapping needed (unlike the `--output-format json` claude envelope). If unmarshalling fails, the executor returns an error that the orchestrator treats as an automatic escalation trigger.
### 2. `internal/exec/verifier.go` — Claude verifier
A focused Claude call that judges local model output. Uses the existing `Executor` (claude subprocess) internally.
```go
type Verdict struct {
Accept bool `json:"accept"`
Feedback string `json:"feedback"` // reason if not accepting; empty if accept
}
type Verifier struct {
executor *Executor // the existing claude executor
}
func NewVerifier(executor *Executor) *Verifier
func (v *Verifier) Verify(ctx context.Context, skillPrompt, taskPrompt string, output Result) (Verdict, error)
```
The verifier prompt gives Claude:
1. The skill discipline file (so it knows the iron laws and output contract)
2. The original task prompt (informed verification — Claude sees what was asked)
3. The generated output
4. A short instruction: "Does this output satisfy the discipline's iron laws and output contract? Reply with JSON: `{\"accept\": true|false, \"feedback\": \"...\"}`"
The verifier uses a lightweight JSON schema for its own output (a `Verdict` schema), keeping the call fast.
### 3. `internal/exec/orchestrator.go` — chain walker
Implements `ExecutorFn`. Walks the escalation chain, delegating generation and verification per tier.
```go
type Chain []ChainEntry
type ChainEntry struct {
Model string // e.g. "ollama/phi4", "claude-sonnet-4-5"
Tier string // "local" | "subagent" | "managed"
IsCloud bool // true for claude-* models; skips verifier
}
type Orchestrator struct {
chain Chain
litellm *LiteLLMExecutor
claude *Executor
verifier *Verifier
llamaSwapURL string // for warm-state probe
}
func NewOrchestrator(chain Chain, litellm *LiteLLMExecutor, claude *Executor, verifier *Verifier, llamaSwapURL string) *Orchestrator
func (o *Orchestrator) Run(ctx context.Context, req Request) (Result, error)
```
Algorithm:
```
for each entry in chain:
warm = probe llama-swap (if local tier)
start = now()
if entry.IsCloud:
result, err = claude.Run(ctx, req with entry.Model)
log attempt(model, tier, duration, warm, verified=true)
if err == nil: return result
else:
result, err = litellm.Run(ctx, req with entry.Model)
duration = now() - start
if err != nil:
log attempt(model, tier, duration, warm, verified=false)
continue // automatic escalation on parse/network error
verdict = verifier.Verify(ctx, req.SkillPrompt, req.TaskPrompt, result)
log attempt(model, tier, duration, warm, verified=verdict.Accept)
if verdict.Accept: return result
// inject verifier feedback into next tier's task prompt
req.TaskPrompt = req.TaskPrompt + "\n\nPrior attempt feedback: " + verdict.Feedback
return error("all tiers exhausted")
```
### 4. `internal/config/models.go` — chain parser
Replaces the current single-model resolution with chain parsing.
Updated `models.yaml` format:
```yaml
verifier: claude-sonnet-4-6 # fixed verifier for all local tiers
llama_swap_url: http://koala:8080 # for warm-state probing
default_chain:
- ollama/qwen3-coder-30b-tuned
- claude-sonnet-4-5
skills:
tdd:
chain:
- ollama/qwen3-coder-30b-tuned
- claude-sonnet-4-5
review:
chain:
- ollama/devstral-tuned
- ollama/gemma4
- claude-sonnet-4-5
debug:
chain:
- ollama/deepseek-r1-tuned
- claude-sonnet-4-5
spec:
chain:
- ollama/phi4
- ollama/gemma4
- claude-sonnet-4-5
- claude-opus-4-6
retrospective:
chain:
- ollama/qwen3-coder-30b-tuned
- claude-sonnet-4-5
trainer:
chain:
- ollama/qwen3-coder-30b-tuned
- claude-sonnet-4-5
```
The parser exposes:
```go
func (m *Models) ChainFor(skill string) Chain
func (m *Models) Verifier() string
func (m *Models) LlamaSwapURL() string
```
Caller override (`model` param in MCP tool call) pins the chain to a single entry — one model, no escalation. This preserves the existing override behaviour for power users.
### 5. `internal/session/session.go` — updated `Attempt` struct
```go
type Attempt struct {
Attempt int `json:"attempt"`
Model string `json:"model"`
Tier string `json:"tier"` // local | subagent | managed
DurationMs int64 `json:"duration_ms"`
WarmStart bool `json:"warm_start"` // model was already loaded in llama-swap
Verified bool `json:"verified"`
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
OutputSummary string `json:"output_summary,omitempty"`
RunnerOutput string `json:"runner_output,omitempty"`
}
```
### 6. `cmd/supervisor/main.go` — one wiring change
```go
// Before:
reg.Register(review.New(review.Config{ExecutorFn: executor.Run, ...}))
// After:
chain := models.ChainFor("review")
orch := exec.NewOrchestrator(chain, litellmExec, claudeExec, verifier, models.LlamaSwapURL())
reg.Register(review.New(review.Config{ExecutorFn: orch.Run, ...}))
```
One orchestrator per skill, sharing the same `litellmExec`, `claudeExec`, and `verifier` instances.
---
## Data flow example: `review` skill call
1. Claude Code calls `review` tool with `files: ["internal/foo.go"]`
2. Skill handler builds task prompt, calls `orch.Run`
3. Orchestrator resolves chain: `[devstral, gemma4, sonnet]`
4. Probes llama-swap: devstral is warm
5. LiteLLM calls devstral → returns JSON result
6. Verifier asks Claude: "does this review satisfy the iron laws?"
7. Claude: `{"accept": false, "feedback": "missing line references for all findings"}`
8. Orchestrator logs attempt #1 (devstral, local, 4200ms, warm, escalate)
9. Injects feedback into task prompt, calls gemma4
10. Verifier: `{"accept": true}`
11. Orchestrator logs attempt #2 (gemma4, local, 6100ms, cold, accept)
12. Returns result to skill handler → MCP response
Session JSONL records both attempts. You can see: devstral was warm but produced weak output; gemma4 was cold but passed.
---
## Observability
Session JSONL is the primary store. Each `Entry.Attempts` slice records the full escalation trail. To analyse across sessions:
```bash
# Which models are escalating most?
jq -r '.attempts[] | select(.verdict == "escalate") | .model' brain/sessions/*.jsonl | sort | uniq -c
# Average latency per model
jq -r '.attempts[] | [.model, .duration_ms] | @tsv' brain/sessions/*.jsonl | awk '{sum[$1]+=$2; n[$1]++} END {for (m in sum) print m, sum[m]/n[m]}'
# Cold start frequency
jq -r '.attempts[] | select(.warm_start == false) | .model' brain/sessions/*.jsonl | sort | uniq -c
```
No new metrics infrastructure needed for Phase 3. Phase 4 can build a dashboard on top of this data.
---
## Error handling
| Scenario | Behaviour |
|----------|-----------|
| LiteLLM unreachable | Log attempt as error, escalate immediately |
| Local model returns unparseable JSON | Log attempt as error, escalate |
| Verifier call fails | Log, treat as escalate (safe default) |
| All tiers exhausted | Return error to skill handler; skill returns MCP error to caller |
| Caller passes `model` override | Single-entry chain, no escalation, no verifier call |
---
## Testing approach
- `TestLiteLLMExecutor`: mock HTTP server returning valid/invalid JSON; verify parse logic and error escalation
- `TestVerifier`: fake claude executor returning accept/escalate verdicts; verify prompt construction
- `TestOrchestrator`: table-driven — chains of 1/2/3 tiers, various accept/escalate/error combinations; verify attempt log contents and final result
- `TestModelsChainFor`: YAML parsing for all skill overrides and default_chain fallback
- Integration smoke test: start real LiteLLM (or mock), call `review` tool via MCP, verify attempt log written
---
## Risks
| Risk | Mitigation |
|------|------------|
| Local models ignore output contract → bad JSON | Discipline files already specify JSON output contract; parse failure auto-escalates |
| Verifier Claude call adds latency to every local attempt | Verifier prompt is small and fast; acceptable tradeoff for quality gate |
| llama-swap warm probe adds overhead | Probe is a single lightweight HTTP GET; timeout at 200ms, treat failure as `warm_start: false` |
| Chain exhaustion leaves caller with no result | Return structured error via MCP; caller can retry with explicit `model` override |

View File

@@ -0,0 +1,240 @@
# Brain Ingestion Pipeline — Design Spec
**Date:** 2026-04-22
**Status:** approved
**Author:** Mathias + Claude
---
## Overview
Add a structured ingestion pipeline to the hyperguild brain. The pipeline accepts raw content (directly or from files) and uses an LLM to produce structured wiki pages in `brain/wiki/` — the declarative layer of the Two-Layer Brain. Three fixed knowledge classes: **concepts**, **entities**, **sources**.
This spec covers:
- Three new packages in the `ingestion` Go module (`llm`, `wiki`, `pipeline`, `watcher`)
- Two new HTTP endpoints on the ingestion server (`/ingest`, `/ingest-path`)
- A background file watcher for `brain/raw/`
- Config additions to both the ingestion server and the supervisor
It does **not** cover Layer 2 (training data, `brain/training-data/`) — that is the trainer worker's concern.
---
## Information Model
Three fixed wiki page classes, matching the Two-Layer Brain design spec and the existing `ingestion-svc` model:
### `wiki/sources/<slug>.md`
One page per ingested source (project, book, article, note). Updated (not replaced) on re-ingestion.
Required frontmatter: `title`, `type` (article|pdf|book|video|note|project), `domain`, `source_url`, `date_ingested`, `last_updated`, `aliases`.
Body sections: Summary · Key Claims · Concepts Introduced or Reinforced · Entities Mentioned · Open Questions Raised. Books add: Chapters · Argument Arc · Updates (dated, append-only).
### `wiki/concepts/<slug>.md`
One page per idea, framework, methodology, or pattern (e.g. Domain Driven Design, TDD, event sourcing).
Required frontmatter: `title`, `domain`, `last_updated`, `aliases`.
Body sections: Definition · Why It Matters · Related Concepts · Related Entities · Sources · Evolving Notes.
### `wiki/entities/<slug>.md`
One page per person, tool, organisation, technology, or product.
Required frontmatter: `title`, `type` (person|company|tool|model|framework|technology), `domain`, `last_updated`, `aliases`.
Body sections: Description · Relevance · Key Positions/Products/Claims · Related Concepts · Related Entities · Sources.
### Wikilink format
All cross-references use `[[slug|Display Text]]`. Slug = lowercase title, spaces→hyphens, non-alphanumeric stripped. Slugs must resolve to an existing file in the wiki.
### Supporting files
- `brain/wiki/index.md` — auto-rebuilt on every ingest: one-sentence summary per page, grouped by type
- `brain/log.md` — append-only audit trail: date, source, pages written, warnings
---
## Architecture
### New packages (`ingestion` module)
```
ingestion/internal/
llm/ — OpenAI-compatible HTTP client (chat completions, retry on 429,
configurable timeout and temperature)
wiki/ — Page types, slug utilities, merge logic, inventory loader,
index rebuilder, log appender
pipeline/ — Orchestrates one ingest run end-to-end (content or extracted file text)
watcher/ — Polls brain/raw/ and triggers pipeline on new files
```
The existing `api/` and `search/` packages are updated; no other existing packages change.
### Brain directory layout
```
brain/
wiki/
concepts/ ← LLM-structured concept pages
entities/ ← LLM-structured entity pages
sources/ ← LLM-structured source pages
index.md ← auto-rebuilt on each ingest
knowledge/ ← quick raw notes via brain_write (BM25-searchable, unchanged)
raw/ ← drop zone; watcher picks up files here
processed/ ← moved here on success (organised by date: processed/YYYY-MM-DD/)
failed/ ← moved here on failure
sessions/ ← session logs (retrospective/trainer concern, not touched here)
training-data/ ← Layer 2 (trainer worker concern, not touched here)
log.md ← append-only audit trail
CLAUDE.md ← schema document injected into every ingest prompt
```
If `brain/CLAUDE.md` is absent, the pipeline falls back to an embedded default schema compiled into the binary.
---
## API
### `POST /ingest`
Ingest content provided directly by the caller.
**Request:**
```json
{
"content": "...",
"source": "shape-up-book",
"dry_run": false
}
```
**Response:**
```json
{
"pages": ["wiki/sources/shape-up.md", "wiki/concepts/betting-table.md"],
"warnings": []
}
```
`source` is the human-readable name used when writing/updating `wiki/sources/<slug>.md`. `dry_run: true` returns the page contents without writing.
### `POST /ingest-path`
Ingest a file or walk a directory recursively. Supports `.md`, `.txt`, `.pdf`.
**Request:**
```json
{
"path": "/Users/mathias/brain/raw/shape-up.pdf",
"source": "shape-up-book",
"dry_run": false
}
```
If `path` is a directory, all supported files within it are ingested in sequence. `source` is optional for directory ingestion — if omitted, the LLM derives it from each file's name and content.
**Response:** same shape as `/ingest`, with pages and warnings aggregated across all files.
### Supervisor skill update
`brain_ingest` in `internal/skills/brain/handlers.go` gains an optional `path` field. If `path` is set, it calls `/ingest-path`; otherwise `/ingest`.
---
## Pipeline
`pipeline.Run(ctx, cfg, brainDir, content, source, dryRun)` — called by both HTTP handlers after any file reading is done.
Steps:
1. **Load inventory** — walk `brain/wiki/{concepts,entities,sources}/`, build slug index grouped by type. Injected into prompt so LLM knows what to update vs create.
2. **Load schema** — read `brain/CLAUDE.md`; fall back to embedded default if absent.
3. **Chunk** — split content at `INGEST_CHUNK_SIZE` chars (default 6000; split on paragraph boundary). If `INGEST_CHUNK_SIZE=0`, no chunking.
4. **LLM call per chunk** — returns JSON array of `{"path": "wiki/concepts/foo.md", "content": "..."}`. Prompt structure: system instruction → date → schema → inventory → non-negotiable slug/wikilink rules → source content.
5. **Parse + truncation recovery** — strip markdown fences if present. If JSON array is truncated mid-object (token limit), salvage all complete objects before the break and log a warning.
6. **Merge** — combine pages with the same path across chunks:
- Bullet sections (Related Concepts, Related Entities, Sources, Key Claims): union unique lines
- Append sections (Evolving Notes, Updates, Open Questions): append new content
- All other sections: keep first occurrence
- Frontmatter: keep first occurrence
7. **Write** — create subdirs as needed, write files atomically. In dry-run mode, return page map without writing.
8. **Rebuild `index.md`** — one-sentence summary per page (derived from first body paragraph), grouped by type, with page count header.
9. **Append to `log.md`** — date, source, list of pages written, warning count.
---
## File Watcher
Background goroutine started at server startup (when `INGEST_WATCH_INTERVAL > 0`).
**Poll loop:**
1. Walk `brain/raw/` for files with supported extensions (`.md`, `.txt`, `.pdf`), excluding `processed/` and `failed/` subdirs.
2. For each file found: derive source from filename (strip extension, kebab-to-title), call `pipeline.Run` with the file content.
3. On success: move file to `brain/raw/processed/YYYY-MM-DD/<filename>`.
4. On failure: move file to `brain/raw/failed/<filename>`, append error to `brain/log.md`.
5. Sleep `INGEST_WATCH_INTERVAL` seconds, repeat.
Files are processed one at a time (no concurrency within the watcher) to avoid LLM rate-limit collisions.
---
## LLM Prompt
**System:**
> You are a wiki agent. Read the source material and produce structured wiki pages following the schema provided. Output ONLY a valid JSON array — no markdown fences, no other text. Each element must have: `"path"` (relative path within wiki, e.g. `"wiki/sources/foo.md"`) and `"content"` (full markdown including YAML frontmatter). Follow the schema strictly: correct frontmatter fields, wikilinks as `[[slug|Display Text]]`, dates in YYYY-MM-DD format, paraphrase rather than quoting verbatim.
**User (built dynamically):**
1. Today's date
2. Full schema (`brain/CLAUDE.md` content)
3. Existing wiki inventory grouped by type (for update-vs-create decisions)
4. Non-negotiable rules: slug format, wikilink format, one-source-per-book, section type enforcement
5. Source content (the chunk)
Temperature: 0.2 for reproducibility.
---
## Configuration
### Ingestion server (new env vars)
| Variable | Default | Description |
|---|---|---|
| `INGEST_LLM_URL` | `http://iguana:4000/v1` | OpenAI-compatible endpoint |
| `INGEST_LLM_KEY` | (empty) | API key |
| `INGEST_LLM_MODEL` | `koala/qwen35-9b-fast` | Model name |
| `INGEST_LLM_TIMEOUT` | `15` | LLM call timeout (minutes) |
| `INGEST_CHUNK_SIZE` | `6000` | Max chars per LLM call (0 = no chunking) |
| `INGEST_WATCH_INTERVAL` | `30` | Watcher poll interval in seconds (0 = disabled) |
### Supervisor (new env vars + wiring)
| Variable | Default | Description |
|---|---|---|
| `INGEST_SVC_URL` | (empty) | URL of ingestion server for `brain_ingest` |
| `KB_RETRIEVAL_URL` | (empty) | URL of KB retrieval server for `brain_search` |
`config.go` gets two new fields. `main.go` passes them to `brain.New()`. Both tools are only registered as MCP tools when the respective URL is configured (already implemented in `skill.go`).
---
## Testing
| Package | What is tested |
|---|---|
| `wiki/` | Slug generation (edge cases: apostrophes, colons, version strings), merge logic (bullets union, append, keep-first), inventory loading from temp dir, truncation recovery (valid partial JSON), index rebuild output |
| `pipeline/` | Integration test: temp brain dir + mock LLM HTTP server returning fixture JSON; verify files written to correct paths, index rebuilt, log appended |
| `api/` | Handler tests for `/ingest` and `/ingest-path` using mock pipeline; 400 on missing fields, 200 with expected response shape |
| `watcher/` | File placed in `brain/raw/` is moved to `processed/` on mock-pipeline success; moved to `failed/` on error |
All tests are table-driven. No real LLM calls in tests.
---
## Out of Scope
- Python validation/correction loop (can be added later; the LLM prompt enforces schema rules as non-negotiable instructions)
- `brain/training-data/` — trainer worker concern
- `brain/sessions/` — retrospective/sessionlog concern
- Upload endpoint (multipart HTTP) — `scp`/rsync to `brain/raw/` + watcher covers this
- Qdrant vector indexing — `brain_search` calls a separate KB retrieval service; ingestion does not write to Qdrant

View File

@@ -0,0 +1,148 @@
# Level 3: Strip Slug Authority from LLM — Design Spec
## Problem
The ingestion pipeline currently asks the LLM to produce full wiki pages including the file path (e.g. `wiki/sources/finbert-huggingface.md`). This causes two classes of bug:
1. **Slug proliferation** — the LLM invents different slugs for the same concept across chunks or runs, producing duplicate pages that diverge in content.
2. **Unstable paths** — the LLM may shorten, expand, or vary titles, making deduplication via `Resolve` unreliable because the slug mismatch is upstream of the normalizer.
## Solution
Strip slug authority from the LLM entirely. The LLM returns a minimal structured object. The pipeline computes all slugs deterministically from titles using `wiki.Slug(title)`.
---
## LLM JSON Contract
### Output format (per page)
```json
{
"title": "FinBERT",
"type": "concept",
"subtype": "framework",
"domain": "ai-llm",
"content": "## Definition\n\nA BERT-based model fine-tuned for financial sentiment...\n\n## Related\n\n- [[Sentiment Analysis]]\n- [[Hugging Face]]\n"
}
```
**Fields:**
| Field | Required | Values |
|-------|----------|--------|
| `title` | yes | Human-readable title, e.g. "FinBERT" |
| `type` | yes | `"source"` \| `"concept"` \| `"entity"` |
| `subtype` | for entity/source | entity: `person\|company\|tool\|model\|framework\|technology`; source: `article\|pdf\|book\|video\|note\|project` |
| `domain` | no | tag string, e.g. `ai-llm`, `finance` |
| `content` | yes | Markdown body sections only — no frontmatter, no path |
**Wikilinks in content:** `[[Display Name]]` only. No slug. The pipeline canonicalizes to `[[slug|Display Name]]` in a post-processing step.
**The LLM never writes slugs, paths, or frontmatter.**
---
## Pipeline Changes
### New type: `RawPage`
```go
type RawPage struct {
Title string
Type string // "source" | "concept" | "entity"
Subtype string
Domain string
Content string
}
```
### New step order
```
ParseRawPages → BuildPages → Resolve → CanonicalizeLinks → injectSourceRefs → mergeAll → write
```
### Step descriptions
**`ParseRawPages(output string) ([]RawPage, []string)`**
Replaces `ParsePages`. Deserializes JSON objects with the new schema. Same truncation-recovery logic as today. Returns `(pages, warnings)`.
**`BuildPages(rawPages []RawPage, sourceSlug, date string) []wiki.Page`**
Converts `RawPage → wiki.Page`:
- Computes slug: `wiki.Slug(page.Title)`
- Computes path: `wiki/<type>/<slug>.md`
- Assembles frontmatter:
```
---
title: <Title>
type: <type>
subtype: <subtype> # omitted if empty
domain: <domain> # omitted if empty
created: <date>
source: <sourceSlug> # omitted for the source page itself
---
```
- Concatenates frontmatter + content
**`Resolve(pages []wiki.Page, inventory) []wiki.Page`**
Unchanged. Normalizes near-duplicate titles to existing inventory slugs.
**`CanonicalizeLinks(pages []wiki.Page, inventory) ([]wiki.Page, []string)`**
New. Builds a title→slug map from inventory + current batch. Replaces `[[Display Name]]` with `[[slug|Display Name]]` in each page's content. Titles with no known slug are left as-is and returned as warnings.
**`injectSourceRefs`**
Unchanged. Reads `[[slug|...]]` links (post-canonicalization) to inject back-references.
**`mergeAll → write`**
Unchanged.
### `pipeline.Run` signature change
```go
func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryRun bool) (Result, error)
```
`source` is already passed (it's the display name / filename). A new internal `sourceSlug` is derived from it via `wiki.Slug(source)` before calling `BuildPages`. No API change needed.
---
## Files Changed
| File | Change |
|------|--------|
| `ingestion/internal/pipeline/parse.go` | Replace `ParsePages` with `ParseRawPages` + `RawPage` type |
| `ingestion/internal/pipeline/build.go` | New file: `BuildPages` |
| `ingestion/internal/pipeline/links.go` | New file: `CanonicalizeLinks` |
| `ingestion/internal/pipeline/pipeline.go` | Wire new steps; derive `sourceSlug` from `source` |
| `ingestion/internal/pipeline/prompt.go` | New system prompt + `BuildPrompt` for new JSON format |
| `brain/schema.md` | Update wikilink format and JSON schema docs |
`resolve.go`, `refs.go`, `backfill.go`, `merge.go` — no changes.
---
## Wikilink Format
- **LLM output**: `[[Display Name]]`
- **Stored on disk**: `[[slug|Display Name]]`
- **`CanonicalizeLinks`** converts between the two using the inventory
This matches Obsidian's display-alias syntax that the existing codebase already uses.
---
## Testing Strategy
- `ParseRawPages`: table-driven, cover valid JSON, truncated output, unknown type, missing title
- `BuildPages`: table-driven, cover slug computation, frontmatter assembly, source page (no `source:` field), entity with subtype
- `CanonicalizeLinks`: cover known title → replaced, unknown title → left as-is + warning, multiple links in one page
- Integration test: full `Run` call with mock LLM returning new JSON format, assert no slug duplication across two chunks of the same source
---
## Out of Scope
- Re-ingesting existing pages (user will trigger manually after deploy)
- Changing the `BackfillRefs` endpoint (already correct, slug-based)
- Changing the `Resolve` fuzzy-match algorithm

View File

@@ -0,0 +1,79 @@
# Spec: hyperguild CLI
> Plan 4 of 7 — Hyperguild Skill Migration. Loaded after `feature-spec` skill.
## Problem Statement
Three needs converge on a single small Go binary:
1. **Tier probing as MCP is overkill.** The supervisor's `tier` MCP runs on `koala:30320` and answers a one-shot question (which models are reachable right now?). Pulling Claude Code through MCP startup, tool listing, and a JSON-RPC call for a 2-second probe is wasteful and adds a network hop the answer doesn't need.
2. **Brain access from shell scripts has no good front door.** The brain's HTTP REST API exists (Plan 1) at `koala:3300` for non-MCP clients, but every shell script that wants to query or write to the brain re-implements the curl invocation. A CLI gives shell pipelines, ad-hoc agent prompts, and quick-debug scenarios a stable interface.
3. **Mode bootstrap is manual.** Each new project that wants to operate in a chosen mode (cloud / client-local / sovereign) needs a `.mcp.json` written by hand. Without automation, mode adoption is gated on remembering the right MCP server URLs.
**Why now:** Plans 13 are merged. The CLI is the next building block in shrinking the supervisor pod toward a thin Mode-2 routing layer. Plans 5 and 6 build on the CLI's tier and brain helpers.
## Success Criteria
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
- [ ] `hyperguild brain write <type> <slug>` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry.
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
## Constraints
- **Stdlib only.** No `cobra`, `urfave/cli`, `viper`, etc. CLI router and flag parsing use `flag.NewFlagSet`.
- **Go 1.26.1**, project default.
- **Module:** `github.com/mathiasbq/supervisor`, peer to `cmd/supervisor/`. New code at `cmd/hyperguild/`. The module name keeps its historical `supervisor` value — renaming the module is out of scope and would touch every import.
- **Reuse `internal/tier`** unchanged. The CLI is a thin wrapper around `tier.Detect`.
- **Brain endpoint configurable** via `BRAIN_URL` env var (default `http://koala:30330` — Tailscale-exposed NodePort, both MCP at `/mcp` and HTTP REST at `/query`, `/write`, etc., share the port). No hostname literals embedded in the CLI body — sourced from env per the existing "logical-addresses-in-instructions" memory.
- **Test discipline:** table-driven, testify, fakes for HTTP and tier probing. No live network in tests.
- **Errors:** wrapped via `fmt.Errorf("op: %w", err)`. No naked returns. Stderr for errors, stdout for results.
## Out of Scope
- The Mode 6 routing pod itself — `mode client-local` writes a placeholder entry pointing at the future routing URL with a `_routing_pending` annotation; the CLI does not provision the pod.
- Pass-rate logging (Plan 5) — the CLI's `brain write` does not emit `session_log` events.
- Skill worker CLIs (`hyperguild tdd_red`, `hyperguild review`, etc.) — those stay on the supervisor MCP until Plan 7.
- Brain HTTP server changes — the REST endpoints already exist.
- Authentication / TLS — Tailscale provides network isolation; no auth currently.
- Windows/Linux binaries — macOS-only per the user's setup. `go build` is portable but no cross-compilation in CI.
- A `crush` config writer for Mode 3 — Mode 3 (sovereign) writes a Claude-Code-compatible `.mcp.json` with brain-only MCP, on the assumption that even Crush-primary users may fall back to Claude Code with brain access. Crush's own config is owned by the user manually.
- A unified `--config` file for the CLI — env var + flags is enough today.
## Technical Approach
- **Single binary, inline subcommand router.** `cmd/hyperguild/main.go` dispatches on `os.Args[1]` to per-subcommand functions, each owning its own `flag.NewFlagSet`. Rationale: 4 top-level subcommands (`tier`, `brain`, `mode`, plus `--help`) and one nested level (`brain query`, `brain write`); ~80 lines of routing plumbing in stdlib beats pulling cobra's ~3 KLOC of dependencies for a tiny CLI. The router is testable by injecting `args []string` instead of reading `os.Args` directly.
- **`tier` subcommand reuses `internal/tier.Detect` verbatim.** Probe URLs (`https://api.anthropic.com` and the LiteLLM base URL) come from environment: `ANTHROPIC_PROBE_URL` (default the literal Anthropic URL) and `LITELLM_BASE_URL` (no default — error if `--mode-needs-llm` and unset). Rationale: matching the supervisor's existing wiring means the CLI cannot disagree with the supervisor about tier; a single source of truth.
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
- `cloud``mcpServers` contains only `brain` at `http://koala:30330/mcp`.
- `client-local``mcpServers` contains `brain` at `http://koala:30330/mcp` and a `routing` placeholder entry with `url` set to a marker (`http://koala:30310/mcp`) and an extra field `"_routing_pending": "Plan 6 — routing pod not deployed yet"`. Rationale: keeping strict-JSON parseable means using a placeholder field rather than a JSON comment, which the spec parser would reject.
- `sovereign``mcpServers` contains only `brain`, plus a top-level `"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback."`.
All three are valid JSON and all three round-trip through `jq` for verification.
Rationale: a single subcommand with three clearly-different outputs is easier to evolve than three nearly-duplicate subcommands. The placeholder fields are intentional documentation in the file itself, which the user actually opens and edits.
- **No global state.** Each subcommand is a function `(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error`, allowing table-driven tests to exercise full subcommand flows without `os.Exit` or fd capture.
- **HTTP client injection.** A package-level `http.Client` with 5s timeout for `brain` calls, overridable in tests via a constructor. Real client for `main`, `httptest.Server` for tests.
## Risks
- **`.mcp.json` schema may evolve.** Claude Code's MCP config format is defined by the harness, and Anthropic could change it. Mitigation: document the format in the CLI's `--help` text and in the spec; if it breaks, the fix is local to one template function.
- **Brain endpoint hostname drift.** If the brain moves off `koala`, the env-var override avoids breaking the CLI but the `mode` template's hardcoded `koala:30330` becomes stale. Mitigation: source the URL in the `mode` template from the same env var (`BRAIN_URL`) so all three subcommands stay in lockstep with the user's actual environment.
- **`tier` probe URL gap.** The CLI inherits the supervisor's hardcoded `https://api.anthropic.com` probe URL via `internal/tier`. If Anthropic changes the URL, both supervisor and CLI break together. Mitigation: env-var override `ANTHROPIC_PROBE_URL`; default unchanged.
- **No HTTP retry logic.** The CLI returns first-error to the user. For ad-hoc shell use this is fine; for automation a future `--retry` flag may be needed. Out of scope for this iteration.
- **Tests don't cover live network.** Pure-fake tests catch regression but not "does the brain pod actually answer." Mitigation: add a smoke-test `task hyperguild:smoke` in a follow-up that runs against the real brain — separate concern, not in Plan 4.
- **Mode 3 sovereign output may surprise users** who expect Mode 3 to skip writing a `.mcp.json` entirely (since Crush is the primary harness). Mitigation: the `_mode_note` field explains the choice; the `--out /dev/null` escape hatch lets users skip the write if they want.

View File

@@ -0,0 +1,125 @@
# Spec: Pass-rate logging
> Plan 5 of 7 — Hyperguild Skill Migration. Loaded after `feature-spec` skill.
## Problem Statement
Plan 6 (Mode 2 routing pod) needs a per-skill signal to decide whether to route a call to the local model or keep it on Claude. The natural signal is recent pass rate: a skill that succeeds 95% of the time on local is safe to route; a skill that succeeds 60% is not. Today there is no such signal — the `session_log` MCP exists (shipped in Plan 1) but skills don't reliably call it, and no endpoint computes pass rate from the resulting logs.
Two consequences:
1. **Plan 6 cannot be trusted without baseline data.** Routing decisions made on guesses will produce regressions that erode confidence in Mode 2 entirely.
2. **The skill library has no observability.** When a skill regresses (model swap, prompt drift, environment change), there's no way to notice until a downstream task explicitly fails.
**Why now:** Plans 14 are merged. Plan 5 instruments the discipline that Plan 6 will consume. Several weeks of usage data between Plan 5 merge and Plan 6 deploy will mean Plan 6 lands on real numbers, not synthetic.
## Success Criteria
- [ ] After Plan 5 merges, every invocation of `tdd` (pilot skill) calls `session_log` at the end of each phase (red, green, refactor) with `final_status` ∈ {pass, fail, skip}.
- [ ] At least 6 of the remaining "binary-outcome" skills get the same treatment: `code-review`, `debug`, `feature-spec`, `session-retrospective`, `trainer`, `spec-driven-dev`. (Skills with no clear pass/fail — `clean-code`, `cognitive-load`, `solid`, `refactoring`, `test-design`, `problem-analysis`, `user-stories`, `planning`, `atdd`, `gitea-ci` — are out of scope.)
- [ ] A new HTTP REST endpoint `GET /pass-rate?skill=X&window=7d` on the brain pod returns valid JSON `{skill, window, pass, fail, skip, total, pass_rate}` for any skill name. Skills with no logged invocations return zeros (not 404, not error). Pass rate is `pass / (pass + fail)`; if `pass + fail == 0`, returns `pass_rate: null`.
- [ ] The endpoint's aggregator normalizes legacy values: `pass``ok`, `fail``error`, `skip``skipped`. No data loss when scanning historical logs.
- [ ] An optional CLI subcommand `hyperguild brain pass-rate <skill> [--window 7d] [--json]` calls the endpoint and prints either human-readable (`tdd: 47 / 50 = 94% (window: 7d)`) or JSON.
- [ ] `task check` passes (lint + test + vet + drift + govulncheck) on each task and on the merged branch.
- [ ] One week post-merge, `GET /pass-rate?skill=tdd&window=7d` returns non-zero counts and a real `pass_rate`.
## Constraints
- **Stdlib + existing deps only.** The endpoint adds to the existing ingestion pod's HTTP handler (Go, `net/http`). No new service, no new pod, no new persistence layer.
- **No auth on `/pass-rate`.** Same model as the rest of the brain HTTP REST API: Tailscale-only network, no token.
- **Schema:** the SKILL.md template uses `pass | fail | skip` for `final_status`. The aggregator treats `pass` and `ok` as equivalent, `fail` and `error` as equivalent, `skip` and `skipped` as equivalent. New writes from skills MUST use the new vocabulary; the aggregator handles both for read-back.
- **Storage:** continues to use the existing JSONL files at `<pod>/brain/sessions/*.jsonl`. No format change. No materialized aggregates. If on-demand scans become slow (>500ms p99), revisit in a follow-up; not now.
- **Backwards compatibility:** the existing `session_log` MCP tool's signature does not change. Its docstring should be updated to reflect the new vocabulary, but argument types stay the same.
- **Pilot-before-rollout:** the first SKILL.md instrumentation (`tdd`) must dogfood successfully — at least one real `tdd` invocation post-instrumentation produces a session log entry — before the other six skills get their updates.
## Out of Scope
- Plan 6 routing pod itself (the consumer of `/pass-rate`).
- Materialized rolling counters (compute on-demand for now).
- Auth, rate limiting, or per-user filtering on `/pass-rate`.
- Dashboards or visualization (`hyperguild brain pass-rate` text/JSON is the only UI).
- Real-time streaming or push notifications (`/pass-rate` is poll-only).
- Skills with no clear binary outcome (the 10 skills listed in Success Criteria).
- Per-model or per-mode breakdown (`session_log` already records `model_used`; the endpoint aggregates across all models for now). Plan 6 may want sharper aggregation; we'll add fields when it lands.
- Migration of the one historical entry in `2026-04-17-validate-hyperguild.jsonl` from `pass` (which is the new vocabulary, by accident) — no migration needed.
## Technical Approach
### Component A — SKILL.md instrumentation pattern
Each instrumented skill gets a standardized "Logging" subsection under its existing "Brain MCP Integration" section. The subsection names the required `session_log` fields with explicit copy-paste examples:
```
**At each phase end:** call `session_log` with:
- `skill`: "<this-skill-name>"
- `phase`: "<the-phase>"
- `final_status`: "pass" | "fail" | "skip"
- `message`: "<one-line summary>"
- `duration_ms`: <wall clock>
- `project_root`: "<absolute path to the project under work>"
```
The pilot SKILL.md (`~/dev/.skills/tdd/SKILL.md`) gets instrumented first. The implementation defines the contract; the rollout commits replicate the pattern across the other six SKILL.md files.
Rationale: SKILL.md as the source of truth means the contract is visible to every agent that loads the skill — no hidden middleware. Mode-agnostic: the agent calls `session_log` whether it's Claude (Mode 1), Claude+routing (Mode 2), or Crush (Mode 3). The pattern is uniform; only the skill name + phase set differ.
### Component B — `/pass-rate` HTTP endpoint
New handler at the existing ingestion pod, peer to `/query`, `/write`, `/ingest`, etc.
```
GET /pass-rate?skill=<name>&window=<duration>
→ 200 { "skill": "tdd", "window": "7d", "pass": 47, "fail": 3, "skip": 0, "total": 50, "pass_rate": 0.94 }
```
Algorithm:
1. Parse `skill` (required) and `window` (default `7d`, accept Go-style `1h`, `12h`, `7d`, `30d`).
2. Walk `brain/sessions/*.jsonl` in the pod's volume. For each line: parse JSON, filter by `skill == query.skill` and `timestamp >= now - window`.
3. Tally `pass` (counts both `pass` and `ok`), `fail` (`fail` and `error`), `skip` (`skip` and `skipped`).
4. Compute `pass_rate = pass / (pass + fail)`; if `pass + fail == 0`, return `pass_rate: null`.
5. Return JSON.
Rationale for on-demand: the JSONL files are append-only and small (one entry per skill phase, kilobytes per session at most). For the first months of Plan 5 usage, scanning all sessions for a single query is fast enough. If it ever isn't, a materialized index is a follow-up — the endpoint shape doesn't change.
### Component C — Optional CLI subcommand
`hyperguild brain pass-rate <skill> [--window 7d] [--json]`. Adds a third nested verb under `brain` (sibling to `query` and `write`). Calls `GET /pass-rate?skill=<>&window=<>` via the existing `brainClient` infrastructure. Default human output: `tdd: 47 / 50 = 94% (window: 7d)`. `--json` passes through the response envelope.
Rationale: shell access to pass-rate without curl + jq. Optional in the strict sense — Plan 6's routing pod will call the endpoint directly, not via the CLI — but cheap to add (one new method on `brainClient`, one new dispatch case in `runBrain`).
### Schema and normalization
`session_log` JSONL line shape (unchanged today, codified by this plan):
```json
{
"session_id": "<id>",
"timestamp": "2026-05-03T20:30:00Z",
"skill": "tdd",
"phase": "red",
"project_root": "/abs/path",
"final_status": "pass",
"duration_ms": 12345,
"message": "Test written, function undefined, red confirmed."
}
```
`final_status` values:
- New writes (this plan onward): `pass | fail | skip`
- Read aggregator accepts both new and legacy: `pass`/`ok` → pass, `fail`/`error` → fail, `skip`/`skipped` → skip
- Anything else → counted as `skip` for safety (don't pollute pass/fail with malformed entries)
### Tests
- Endpoint: table-driven tests with a temp `brain/sessions/` directory containing JSONL files spanning multiple skills, multiple statuses (both vocabularies), edge cases (empty file, malformed line, timestamp outside window, future timestamp). Tests run via `httptest.NewServer` against the real handler.
- CLI: tests for `runBrainPassRate` against `httptest.Server` fake of `/pass-rate`. Human and `--json` output paths.
- Pilot dogfood: after instrumenting `tdd/SKILL.md`, one real TDD task in this plan exercises the logging path. The corresponding session log entry verifies end-to-end.
- `task check` per task.
## Risks
- **Skills that don't reliably log produce missing data.** The aggregator returns zero counts for those, which Plan 6 may misread as "this skill always passes" or "this skill is broken". Mitigation: the endpoint returns `pass_rate: null` when `pass + fail == 0`, signalling "no data" distinct from "always passes". Plan 6 must check for null.
- **Agents may forget to call `session_log` mid-skill.** No way to enforce in cloud Mode 1 — Claude may skip the call if instructions are unclear. Mitigation: SKILL.md template makes the call literal and copy-pasteable. After 1 week, if instrumentation rate is < 80% of expected calls, escalate; consider a wrapper at the routing-pod layer in Plan 6 as belt-and-suspenders.
- **Schema drift between legacy `ok` and new `pass`.** Mitigation: the aggregator's normalization rule. Documented in the endpoint's response and in the `session_log` tool docstring update.
- **`/pass-rate` walks all session files for each request.** With ~1 file per session and tens of sessions per week, this is microseconds today. At hundreds of files per day, may need a date-bounded directory layout. Mitigation: monitor; if scan time > 100ms p99, revisit. Not in this plan.
- **The pilot may fail on the first dogfood.** If `tdd` instrumentation doesn't produce a log entry (e.g. agent didn't call `session_log`, JSON shape wrong, file permissions), the rollout to the other six skills is blocked until the pilot succeeds. Mitigation: explicit "pilot validates end-to-end" gate as the last step of Component A.
- **Adding a third verb under `brain` slightly stretches the inline-router pattern.** Three verbs in a switch is still simple; if it grows to five, the CLI may want a per-verb registration map. Mitigation: deferred — three is fine.

View File

@@ -0,0 +1,240 @@
# Spec: Mode 2 routing pod
> Plan 6 of 7 — Hyperguild Skill Migration. Loaded after `feature-spec` skill.
## Problem Statement
Mode 2 (`client-local`) is the cost-and-sovereignty mode for paid client work — keep skill calls inside Tailscale, save tokens, but stay reliable. Plans 15 produced everything Mode 2 needs except the consumer: the brain MCP at `:30330` is live, four skills are instrumented to log `pass | fail | skip`, and `GET /pass-rate?skill=X&window=Y` returns honest numbers (or `null` when there is no data). What is still missing is the policy layer that reads pass-rate and acts on it.
The supervisor pod (`:30320`) historically hosted full skill workers (`tdd_red/green/refactor`, `code_review`, `debug`, `spec`, `retrospective`, `trainer`, `tier`) but with no routing — every call ran local regardless of skill quality, and Claude Code in client-local mode silently lost access to Claude-quality work even when local was wrong. That's the regression Plan 6 fixes.
**Why now:** the supervisor pod is scheduled for retirement (Plan 7) and the data plumbing for routing decisions exists but has no consumer. Without Plan 6, Plan 7 cannot land.
## Success Criteria
- [ ] A new pod `routing` is deployed via Flux at NodePort `:30310`, alongside (not replacing) the supervisor and ingestion pods. Image built by gitea CI, deployment manifest under `infra/k3s/apps/routing/`. `kubectl -n routing get deployment` shows `1/1 Ready`.
- [ ] `POST http://koala:30310/mcp` responds to `tools/list` with exactly four tools: `code_review`, `debug`, `retrospective`, `trainer`. Each tool's name + JSON schema is byte-identical to the supervisor's current advertisement (verified by snapshot test).
- [ ] Bearer-token auth via env var `ROUTING_MCP_TOKEN` (same opt-in pattern as `SUPERVISOR_MCP_TOKEN` shipped in `f49850d`). Empty token = no auth; populated token = `Authorization: Bearer <token>` required, otherwise HTTP 401 + JSON-RPC `-32001`.
- [ ] On every tool call, the pod queries `${BRAIN_URL}/pass-rate?skill=<tool>&window=7d` and applies a configurable policy:
- `pass_rate == null` → route to local (default-to-local)
- `pass_rate ≥ HYPERGUILD_ROUTE_LOCAL_FLOOR` (default `0.90`) → route to local
- `HYPERGUILD_ROUTE_LOCAL_CEIL ≤ pass_rate < FLOOR` (CEIL default `0.70`) → 50/50 deterministic sample (hash of canonical request body)
- `pass_rate < CEIL` → route to Claude
- [ ] Both routes resolve to a LiteLLM call: local route uses `HYPERGUILD_LOCAL_MODEL` (default `qwen35`), Claude route uses `HYPERGUILD_CLAUDE_MODEL` (default `claude-sonnet-4-6`). LiteLLM at `${LITELLM_BASE_URL}` (default `http://piguard:4000`) handles provider routing. The routing pod has no direct Anthropic SDK.
- [ ] Every routing decision is logged via `session_log` to the brain pod with `{skill: "_routing", phase: "decide", final_status: "skip", message: "<tool>: <decision>", duration_ms, project_root}`. `final_status: "skip"` keeps these entries out of any skill's pass-rate aggregation.
- [ ] LiteLLM unreachable → fail open to a Claude decision *and* log `final_status: "fail"` for `_routing`. The pod must still serve requests even if LiteLLM is down for hours.
- [ ] `cmd/hyperguild/mode.go` updated: `mode client-local` writes the routing entry with `"headers": {"X-Hyperguild-Mode": "client-local"}` and the `_routing_pending` placeholder field is removed. The pod accepts but does not branch on the header (forward-compat only).
- [ ] `task check` (lint + test + vet + drift + govulncheck) passes on each task and on the merged branch. The CI gate that bit Plan 1 must not bite Plan 6 (per `feedback_per_task_verification` memory).
- [ ] A new `task smoke:routing` target boots the binary against the live LiteLLM at `piguard:4000` and the live `/pass-rate` at `koala:30330`, calls each of the four advertised tools once, and verifies a `_routing` entry appears in the brain via `GET /pass-rate?skill=_routing&window=1h`. This is the live-contract test (per `2026-05-03-fake-tests-vs-real-contract` brain entry); fake-server unit tests verify policy logic, the smoke step verifies the contract.
- [ ] Mode 1 (`cloud`) and Mode 3 (`sovereign`) are byte-identically unchanged. Verified by `git diff` showing no changes to `mode.go`'s `modeCloud` or `modeSovereign` functions.
## Constraints
- **Stdlib + existing deps only.** The routing pod reuses `internal/exec/litellm.go` (`NewLiteLLM`, `Complete`), `internal/registry`, and `internal/skills/{review,debug,retrospective,trainer}/`. No new third-party dependency. Auth code may be duplicated from `internal/mcp/server.go` or extracted to a shared helper — implementer's call.
- **No new persistence.** Pass-rate data lives in the brain pod's session JSONL files (Plan 5). Routing-decision logs land in the same place via `session_log`. Routing pod has no DB, no cache, no on-disk state beyond an optional in-memory pass-rate cache (TTL = 60 seconds — protects the brain from per-call hammering during an active session).
- **MCP wire format identical to supervisor's.** Tools have the same names and JSON schemas as today. A consumer switches modes by changing only the URL in `.mcp.json` — no schema-level differences. Snapshot tests pin this.
- **Pod must start and serve degraded.** If LiteLLM is down at startup, the pod still binds to `:3210`, advertises tools, and serves requests with the fail-open-to-Claude behavior described in success criteria.
- **`internal/skills/{review,debug,retrospective,trainer}/` survives Plan 6.** Plan 7's note about deleting them is amended: those four packages are reused by the routing pod and must NOT be deleted in Plan 7. Plan 7 deletes only `internal/skills/{tdd,spec}/`, the supervisor binary, the supervisor manifests, and frees NodePort `:30320`. This spec calls out the change so Plan 7's author doesn't delete needed code (per `2026-05-03-implicit-cleanup-third-category` brain entry).
- **No retries beyond fail-open.** A LiteLLM call that errors becomes a Claude decision and a `final_status: "fail"` log. No exponential backoff, no circuit breaker — that's policy for a future plan once the failure shape is observed.
- **Determinism in sampling.** When pass-rate is in the sample band (`CEIL ≤ pr < FLOOR`), the local-vs-Claude choice for a given request is reproducible: hash a canonical JSON of the request body, low bit picks local. Same input → same decision. Avoids per-call variance confusing the operator during a debugging session.
## Out of Scope
- **Plan 7 (supervisor retirement).** Separate plan, executed after Plan 6 stabilizes. Plan 6 leaves the supervisor pod running; nothing about supervisor changes in this plan.
- **Routing for `tdd_red/green/refactor`, `spec`, `tier`.** Per `project_per_skill_routing.md`, these are SKILL.md or CLI, not routing-pod tools. They never appear in the routing pod's `tools/list`. If a future plan changes that decision, it adds them then.
- **Routing for `brain_ingest`.** Already routed at the brain pod (Plan 1). No change.
- **Per-mode policy branching.** The pod accepts `X-Hyperguild-Mode` for forward-compat but treats absent or unknown values as `client-local`. No code path differs on the header value yet.
- **OAuth, IP allowlisting, rate limiting, audit logging.** Bearer-token only; same risk model as the supervisor MCP after `f49850d`.
- **Decision-log read endpoints.** Routing decisions land in the brain via `session_log`. Reads happen via the existing `GET /pass-rate` endpoint and JSONL inspection. No new read API.
- **Materialized routing-decision aggregates.** Out of scope for the same reason Plan 5 deferred materialized counters: on-demand scans are fast enough at current data volumes.
- **Tunable per-skill thresholds.** `FLOOR` and `CEIL` are global. If the operator decides `debug` needs a different floor than `code_review`, that's a follow-up plan with real data behind the choice.
- **Sampling beyond a 50/50 hash split.** No epsilon-decay schedules, no Thompson sampling, no per-skill exploration policies. Add when data justifies.
- **Migration of any existing supervisor-skill `.mcp.json` registrations.** Consumers update their `.mcp.json` (via `hyperguild mode client-local`) when they want Mode 2 behavior. No silent redirect.
- **Routing-pod-side prompt customization.** The four skill packages already own their prompts; the routing pod just calls into them via the existing `Skill` interface. Prompt edits remain a SKILL.md or `internal/skills/<x>/` concern.
## Technical Approach
### A. Binary layout: `cmd/routing/`
A new Go binary at `cmd/routing/main.go`. Stdlib + `internal/*`. Wires:
1. Config from env (typed struct in `internal/config/routing.go` — peer to `Config` for the supervisor; deliberately a separate type because the surfaces are different and merging would force every routing-pod field onto the supervisor and vice versa).
2. `internal/exec/litellm.NewLiteLLM(...)` — same client the supervisor uses.
3. `internal/skills/{review,debug,retrospective,trainer}.New(...)` constructors, each receiving a `CompleteFunc` that wraps the routing decision (see C below).
4. `internal/registry.New()` populated with the four skills.
5. `internal/mcp.NewServer(reg, cfg.MCPAuthToken)` — reuse the existing handler with bearer auth from `f49850d`. The handler is generic; nothing in it is supervisor-specific.
**Rationale:** the supervisor's runtime is already 80% of what the routing pod needs. Reusing it saves the routing pod from re-implementing skill dispatch, MCP protocol handling, and bearer auth. The only new code is the routing decision itself (C below) and the deployment manifests (G).
### B. Configuration via env
Typed struct, parsed at startup. New env vars introduced by Plan 6:
| Env var | Default | Purpose |
|---|---|---|
| `ROUTING_PORT` | `3210` | Pod's HTTP port (NodePort `:30310` maps to this) |
| `ROUTING_MCP_TOKEN` | — | Bearer token, opt-in (empty = no auth) |
| `LITELLM_BASE_URL` | `http://piguard:4000` | LiteLLM proxy (reused) |
| `LITELLM_API_KEY` | — | Reused, sourced from `routing-secrets` Secret |
| `BRAIN_URL` | `http://ingestion.supervisor:3300` | In-cluster brain pod for `/pass-rate` and `session_log` |
| `HYPERGUILD_LOCAL_MODEL` | `qwen35` | Model name passed to LiteLLM for the local decision |
| `HYPERGUILD_CLAUDE_MODEL` | `claude-sonnet-4-6` | Model name for the Claude decision |
| `HYPERGUILD_ROUTE_LOCAL_FLOOR` | `0.90` | At/above this pass-rate, always local |
| `HYPERGUILD_ROUTE_LOCAL_CEIL` | `0.70` | Below this, always Claude. Between CEIL and FLOOR is the sample band. |
| `HYPERGUILD_PASS_RATE_TTL_SECONDS` | `60` | Per-skill in-memory cache TTL |
**Rationale:** every value an operator might want to tune is an env var, not a hardcoded constant. Defaults are the recommendations from the kickoff and the per-skill-routing memory; sensible cluster values flow in via the Flux-managed Secret. No config file to manage.
### C. Decision policy (`internal/routing/policy.go`)
Pure function, no I/O:
```go
type Decision int
const (
DecideLocal Decision = iota
DecideClaude
)
type Policy struct{ Floor, Ceil float64 }
// Decide returns the routing decision. passRate may be nil when the brain has no data.
// requestHash is a deterministic 64-bit hash of the canonical request body — used only
// when passRate is in the sample band; same hash → same decision.
func (p Policy) Decide(passRate *float64, requestHash uint64) Decision { ... }
```
Rules (in order):
1. `passRate == nil``DecideLocal` (default-to-local)
2. `*passRate >= p.Floor``DecideLocal`
3. `*passRate < p.Ceil``DecideClaude`
4. Otherwise (sample band) → `requestHash & 1` picks local on `0`, claude on `1`
**Rationale:** no I/O in the policy means the function is trivially testable (table-driven, no fixtures, no servers). Network calls happen in a wrapping layer that calls `Decide` — same separation as `internal/skills/*/skill.go` keeps prompt strings separate from `Complete` calls. Default-to-local rule is justified in `project_per_skill_routing.md`: the four advertised skills are exactly the skills marked "MCP→local" in that target architecture.
### D. Pass-rate fetcher (`internal/routing/passrate.go`)
```go
type Fetcher struct {
BaseURL string
HTTPClient *http.Client // 1s timeout
Cache *ttlCache // map[string]*float64 with 60s TTL, struct-internal
}
func (f *Fetcher) Get(ctx context.Context, skill string) (*float64, error)
```
Calls `GET ${BaseURL}/pass-rate?skill=<skill>&window=7d`. On success, caches the parsed `pass_rate` (which may be `null`) for `HYPERGUILD_PASS_RATE_TTL_SECONDS`. On error, returns `(nil, err)`; the dispatch wrapper treats this as `*passRate == nil` and routes to local (the default-to-local fallback also covers brain-pod-down).
**Rationale:** GET is correct REST per `2026-05-03-rest-semantics-vs-precedent` (this is a pure read with query params; it shouldn't follow the legacy POST-everywhere precedent). Cache TTL of 60s prevents per-call hammering during a tight Claude Code loop while staying fresh enough that a flapping pass-rate visibly affects routing within a minute. No persistence — restart loses cache, that's fine.
### E. Dispatch wrapper
The four skills are constructed with their existing `CompleteFunc` signature (`(ctx, model, system, user) (string, int64, error)`). The routing pod wraps it:
```go
func (r *Router) Complete(ctx context.Context, skill, model, system, user string) (string, int64, error) {
pr, _ := r.fetcher.Get(ctx, skill)
decision := r.policy.Decide(pr, hashCanonical(system, user))
chosenModel := r.cfg.ClaudeModel
if decision == DecideLocal {
chosenModel = r.cfg.LocalModel
}
out, ms, err := r.litellm.Complete(ctx, chosenModel, system, user)
r.logDecision(skill, decision, err, ms)
if err != nil {
// fail open: try Claude once if we routed local; if Claude also fails, return error.
if decision == DecideLocal {
chosenModel = r.cfg.ClaudeModel
out, ms, err = r.litellm.Complete(ctx, chosenModel, system, user)
r.logDecision(skill, DecideClaude, err, ms) // second log entry, marked fail if still erroring
}
return out, ms, err
}
return out, ms, nil
}
```
The skill packages don't know about routing — they receive a `CompleteFunc` and call it. The wrapper substitutes routing logic at construction time.
**Rationale:** keeps the skill packages oblivious to mode. Same `internal/skills/review/` works under the supervisor (no routing) and under the routing pod (routed) without any conditional logic in the skill itself. Plan 7's deletion of the supervisor leaves the skills' shape intact for the routing pod.
### F. Decision logging (`internal/routing/log.go`)
After every decision, POST a session log entry to `${BRAIN_URL}/write` (the brain pod's existing endpoint, which appends to `brain/sessions/<session>.jsonl`). Entry shape:
```json
{
"skill": "_routing",
"phase": "decide",
"final_status": "skip",
"message": "<original_skill>: <decision> (pass_rate=<value or 'null'>, model=<chosen>)",
"duration_ms": <litellm_round_trip>,
"project_root": "<path from request, or 'unknown'>",
"timestamp": "<RFC3339>",
"session_id": "<from request, or generated>"
}
```
`final_status: "skip"` keeps these entries out of any real skill's pass-rate aggregation (Plan 5's aggregator counts only `pass`/`fail`). Operators can still query `GET /pass-rate?skill=_routing&window=7d` for routing-failure visibility (when LiteLLM down → `final_status: "fail"` in the second log entry).
**Rationale:** closes the observability loop without adding a new endpoint or schema. `_routing` namespaces routing entries away from skill names. `skip` is the only honest classification — routing isn't itself a pass/fail event in the skill sense.
### G. Deployment
New manifest directory `infra/k3s/apps/routing/` mirroring `infra/k3s/apps/supervisor/`'s shape:
- `namespace.yaml` — namespace `routing` (peer to `supervisor`)
- `deployment.yaml` — single replica, nodeSelector koala, image from gitea registry, `envFrom: secretRef: routing-secrets`
- `service.yaml` — ClusterIP on port 3210
- `nodeport.yaml` — NodePort 30310 → service 3210
- `secrets.enc.yaml` — SOPS-encrypted, contains `LITELLM_API_KEY` and (optionally) `ROUTING_MCP_TOKEN`
- `kustomization.yaml` — bundles the above
The supervisor pod's CI image build pattern (gitea Actions → `gitea.d-ma.be/mathias/supervisor:<sha>`) is replicated for `gitea.d-ma.be/mathias/routing:<sha>`. Flux's existing image-automation will bump the manifest's image tag on each push.
**Rationale:** copying the supervisor pod's manifest shape (rather than designing from scratch) is the YAGNI move. Flux + image automation already proven on supervisor; same pattern, same operator mental model. Mode 2 setup is now a Flux change, not a one-off `kubectl` ritual.
### H. Live smoke test
`task smoke:routing` (in the project Taskfile) does:
1. Boot the binary locally with `LITELLM_BASE_URL=http://piguard:4000` and `BRAIN_URL=http://koala:30330`. Bind to a random localhost port (so it doesn't conflict with anything else).
2. Send `tools/list` and assert four tool names.
3. For each tool, send a minimal valid `tools/call`. Don't assert on response content — assert response shape (no error, has content).
4. After all four calls, query `GET http://koala:30330/pass-rate?skill=_routing&window=1h` and assert `total >= 4`.
5. Tear down.
Skipped automatically when LiteLLM is unreachable or when run outside Tailscale (tier 3) — emits a `SKIP` line and exits 0. `task check` does NOT include `task smoke:routing` (CI runner doesn't have Tailscale); operator runs it manually before bumping production.
**Rationale:** unit tests with `httptest.Server` fakes verify the policy and the dispatch wrapper logic. The smoke test is the only thing that will catch a contract drift between the routing pod's `Complete` calls and the actual LiteLLM API, or a schema drift between `/pass-rate` and what the fetcher expects (per `2026-05-03-fake-tests-vs-real-contract`).
### I. Mode-template update (`cmd/hyperguild/mode.go`)
`modeClientLocal` is amended:
- The `routing` entry's `url` stays at `http://koala:30310/mcp`.
- A new key `headers` is added with `{"X-Hyperguild-Mode": "client-local"}`.
- The placeholder `_routing_pending` field is **removed**, since the routing pod now exists.
Tests in `cmd/hyperguild/mode_test.go` are updated to assert the new structure. README in `cmd/hyperguild/README.md` updated to drop the "not deployed yet" note.
**Rationale:** Plan 4 deliberately scaffolded the placeholder for Plan 6 to fill in. This is the fill-in. Removing `_routing_pending` is the implicit cleanup the kickoff anticipates — making it explicit in the spec avoids a Plan-completeness gap (per `2026-05-03-implicit-cleanup-third-category`).
## Risks
- **Empty pass-rate window in the first weeks.** Plans 35 merged on 2026-05-03; usage data has not accumulated. With default-to-local active for all four routed skills, the first weeks of Mode 2 = "everything goes local." If local quality is rough on `code_review` or `debug`, the operator's first impression of Mode 2 is bad, and confidence in Plan 6 erodes before data lands. **Mitigation:** the FLOOR / CEIL are env-tunable. If local quality is unworkable in the first week, set `HYPERGUILD_ROUTE_LOCAL_FLOOR=2.0` (impossible threshold) and the pod becomes default-to-Claude with no code change. This is a deliberate kill switch for the early window.
- **LiteLLM-as-single-dependency.** The routing pod has exactly one upstream LLM provider: `piguard:4000`. If LiteLLM is misconfigured (wrong model name routed to wrong provider, expired Anthropic key in LiteLLM's config), every routing-pod call returns garbage. **Mitigation:** the smoke test catches gross misconfig before deploy; once deployed, LiteLLM's own `/health` endpoint is the canary (the pod doesn't probe it — operator monitors LiteLLM separately). If a deeper failure mode emerges, add a routing-pod liveness probe in a follow-up.
- **Skill-schema drift.** The routing pod's `tools/list` is asserted byte-identical to the supervisor's via snapshot test. If someone evolves the supervisor's schemas between Plan 6 merge and Plan 7 (a long window), the snapshot drifts. **Mitigation:** the spec documents that Plan 6 freezes the schemas; supervisor edits to skill schemas are out of scope until Plan 7 deletes the supervisor. This is a soft constraint enforced by the spec, not by code. If the supervisor genuinely needs a schema change before Plan 7, that's a separate plan.
- **Flux drift on `kubectl rollout restart`.** Demonstrated during the bearer-auth rollout earlier today: Flux server-side-applies the deployment every 30s and strips the `kubectl.kubernetes.io/restartedAt` annotation, which deletes the new ReplicaSet's pod. **Mitigation:** the Plan 6 implementer prompt and the README note that `kubectl delete pod -l app=routing` is the correct way to force a restart on Flux-managed deployments — the existing ReplicaSet recreates without an annotation Flux can revert. (This finding is worth a brain entry; capture in retrospective.)
- **Mode header not forwarded by Claude Code.** Plan 6 assumes Claude Code propagates `headers` from `.mcp.json`. The bearer-auth rollout proved this works for `Authorization`. The same path should work for `X-Hyperguild-Mode`. **Mitigation:** the pod treats absent header as `client-local` (the only mode that registers the pod). If forwarding silently breaks, behavior is identical — header is forward-compat only.
- **Sample-band hash collision producing skewed routing.** Hash inputs are `(system, user)` strings. If skill prompts produce highly similar bodies (debug bug A vs debug bug B with similar wording), low-bit hash distribution might cluster on one side. **Mitigation:** at the volumes Plan 6 expects (single operator, ~10s of routed calls/hour at peak), bias is statistically invisible. If volume ever rises, swap `hash & 1` for a stronger split. Not the first failure mode worth pre-engineering.
## Cross-references
- Spec for Plan 5 (consumer of `/pass-rate`): `docs/superpowers/specs/2026-05-03-pass-rate-logging-design.md`
- Spec for Plan 4 (which scaffolded the `:30310` placeholder): `docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md`
- Auto-memory entries `project_three_modes`, `project_skill_migration_plans`, `project_per_skill_routing`, `feedback_per_task_verification`, `feedback_sudo`
- Brain entries `2026-05-03-rest-semantics-vs-precedent`, `2026-05-03-aggregator-normalization-backwards-compat`, `2026-05-03-fake-tests-vs-real-contract`, `2026-05-03-implicit-cleanup-third-category`, `2026-05-03-code-reviewer-output-as-candidates`, `2026-05-03-done-with-concerns-vs-blocked`, `2026-05-03-verification-depth-formula`, `2026-05-03-plan-canonical-dispatch-ephemeral`

17
go.mod
View File

@@ -2,10 +2,23 @@ module github.com/mathiasbq/supervisor
go 1.26.1
require github.com/stretchr/testify v1.11.1
require (
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/sys v0.31.0 // indirect
)

27
go.sum
View File

@@ -1,10 +1,37 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

45
ingestion/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS builder
ARG VERSION=dev
WORKDIR /src
# Fetch internal gitea-hosted Go modules (mcp-chassis) without going through
# proxy.golang.org and without HTTP→HTTPS surprises. The Gitea server returns
# http:// in its go-import meta tag (config-level limitation), so rewrite to
# https here and bypass the module proxy + sumdb.
RUN git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
ENV GOPRIVATE=gitea.d-ma.be
ENV GOPROXY=direct
ENV GOSUMDB=off
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" \
-o /out/ingestion ./cmd/server
FROM alpine:3.21
RUN apk add --no-cache poppler-utils
COPY --from=builder /out/ingestion /usr/local/bin/ingestion
RUN addgroup -S ingestion && adduser -S -G ingestion ingestion
WORKDIR /app
# brain/ is writable state — mount a PersistentVolume here
VOLUME /app/brain
ENV INGEST_BRAIN_DIR=/app/brain
ENV INGEST_PORT=3300
USER ingestion
EXPOSE 3300
ENTRYPOINT ["/usr/local/bin/ingestion"]

View File

@@ -2,35 +2,382 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/claudewatcher"
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
"github.com/mathiasbq/hyperguild/ingestion/internal/oauth"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
)
// claudeSink converts each claudewatcher.Batch into one wiki note under
// brain/wiki/claude-sessions/facts/. v1 emits one note per session
// keyed by host + session id; classifier-driven hall routing is a
// follow-up (hyperguild#27 v2).
type claudeSink struct {
brainDir string
logger *slog.Logger
}
func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
if len(b.Turns) == 0 {
return nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "# Claude session %s (%s)\n\n", b.SessionID, b.Host)
fmt.Fprintf(&sb, "_Project: `%s`. File: `%s`. Turns: %d._\n\n", b.ProjectID, b.FilePath, len(b.Turns))
for _, t := range b.Turns {
fmt.Fprintf(&sb, "## %s — %s\n\n", t.Type, t.Timestamp.UTC().Format(time.RFC3339))
if t.ToolName != "" {
fmt.Fprintf(&sb, "_tool: `%s`_\n\n", t.ToolName)
}
// Cap per-turn excerpt to keep page size bounded; the full
// transcript lives on disk under ~/.claude/projects/ already.
content := t.Content
if len(content) > 2000 {
content = content[:2000] + "…"
}
sb.WriteString(content)
sb.WriteString("\n\n")
}
slug := "session-" + b.Host + "-" + b.SessionID
if _, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
Filename: slug,
Wing: "claude-sessions",
Hall: "facts",
Type: "source",
Domain: b.ProjectID,
Content: sb.String(),
}); err != nil {
return fmt.Errorf("write claude session note: %w", err)
}
return nil
}
// redactDSN parses a Postgres URL and replaces its password with `***`
// for safe inclusion in logs. Falls back to a non-leaking placeholder
// if parsing fails — we never log a raw DSN.
func redactDSN(dsn string) string {
u, err := url.Parse(dsn)
if err != nil || u.User == nil {
return "postgres://***"
}
return u.Redacted()
}
// vectorAdapter bridges *vectorstore.PGStore (returns []vectorstore.Hit)
// to the search.VectorSearcher interface (which uses []search.VectorHit).
// Kept here, not in either package, so neither has to import the other.
type vectorAdapter struct{ s *vectorstore.PGStore }
func (a vectorAdapter) Search(ctx context.Context, q []float32, limit int) ([]search.VectorHit, error) {
hits, err := a.s.Search(ctx, q, limit)
if err != nil {
return nil, err
}
out := make([]search.VectorHit, len(hits))
for i, h := range hits {
out[i] = search.VectorHit{Path: h.Path, Distance: h.Distance}
}
return out, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
// systemHostname returns os.Hostname() with a "unknown" fallback so the
// caller never has to handle the rare error path.
func systemHostname() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "unknown"
}
return h
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
brainDir := os.Getenv("INGEST_BRAIN_DIR")
if brainDir == "" {
brainDir = "../brain"
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
port := envOr("INGEST_PORT", "3300")
llmURL := envOr("INGEST_LLM_URL", "http://iguana:4000/v1")
llmKey := os.Getenv("INGEST_LLM_KEY")
llmModel := envOr("INGEST_LLM_MODEL", "koala/qwen35-9b-fast")
llmTimeoutMins := envInt("INGEST_LLM_TIMEOUT", 15)
chunkSize := envInt("INGEST_CHUNK_SIZE", 6000)
watchInterval := envInt("INGEST_WATCH_INTERVAL", 30)
llmClient := llm.New(llmURL, llmKey, llmModel, time.Duration(llmTimeoutMins)*time.Minute)
pipelineCfg := pipeline.Config{
Complete: llmClient.Complete,
ChunkSize: chunkSize,
}
port := os.Getenv("INGEST_PORT")
if port == "" {
port = "3300"
h := api.NewHandler(brainDir, logger, pipelineCfg)
var answerComplete pipeline.CompleteFunc
if primaryURL := os.Getenv("BRAIN_LLM_PRIMARY_URL"); primaryURL != "" {
primaryModel := envOr("BRAIN_LLM_PRIMARY_MODEL", "gemma4:31b")
primaryKey := os.Getenv("BERGET_API_KEY")
timeoutMS := envInt("BRAIN_LLM_TIMEOUT_MS", 10000)
timeout := time.Duration(timeoutMS) * time.Millisecond
primary := llm.New(primaryURL, primaryKey, primaryModel, timeout)
router := &llm.Router{Primary: primary}
if fallbackURL := os.Getenv("BRAIN_LLM_FALLBACK_URL"); fallbackURL != "" {
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
}
answerComplete = router.Complete
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
}
h := api.NewHandler(brainDir, logger)
mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete, answerComplete)
if rerankURL := os.Getenv("BRAIN_RERANKER_URL"); rerankURL != "" {
rerankModel := envOr("BRAIN_RERANKER_MODEL", "dengcao/Qwen3-Reranker-0.6B:F16")
mcpSrv = mcpSrv.WithReranker(reranker.New(rerankURL, rerankModel))
logger.Info("brain reranker configured", "url", rerankURL, "model", rerankModel)
}
// Hybrid retrieval (pgvector + nomic-embed-text). Both env vars must
// be set together for the path to wire on; otherwise BM25-only.
var vectorStore *vectorstore.PGStore
pgDSN := os.Getenv("BRAIN_PG_DSN")
embedURL := os.Getenv("BRAIN_EMBED_URL")
switch {
case pgDSN != "" && embedURL != "":
embedModel := envOr("BRAIN_EMBED_MODEL", "nomic-embed-text:latest")
store, err := vectorstore.New(context.Background(), pgDSN)
if err != nil {
logger.Error("vector store init", "err", err)
os.Exit(1)
}
if err := store.Init(context.Background()); err != nil {
logger.Error("vector store migrate", "err", err)
os.Exit(1)
}
vectorStore = store
embedder := embed.New(embedURL, embedModel)
mcpSrv = mcpSrv.WithHybridRetrieval(vectorAdapter{s: store}, embedder)
h.WithEmbedSync(store, embedder)
logger.Info("brain hybrid retrieval enabled",
"pg", redactDSN(pgDSN),
"embed_url", embedURL, "embed_model", embedModel)
// Graph store shares the same postgres18 DSN as the vector
// store and is opt-in via BRAIN_GRAPH_ENABLED=true. Defaults
// to off so first rollout doesn't surprise — flip on after
// the migration completes and the backfill finishes.
if envOr("BRAIN_GRAPH_ENABLED", "false") == "true" {
gstore, gerr := graphstore.New(context.Background(), pgDSN)
if gerr != nil {
logger.Error("graph store init", "err", gerr)
os.Exit(1)
}
if gerr := gstore.Init(context.Background()); gerr != nil {
logger.Error("graph store migrate", "err", gerr)
os.Exit(1)
}
mcpSrv = mcpSrv.WithGraph(gstore)
if envOr("BRAIN_GRAPH_BACKFILL", "false") == "true" {
n, berr := graphsync.BackfillFromBrainDir(context.Background(), gstore, brainDir)
if berr != nil {
logger.Warn("graph backfill incomplete", "indexed", n, "err", berr)
} else {
logger.Info("graph backfill complete", "indexed", n)
}
}
logger.Info("brain graph enabled", "pg", redactDSN(pgDSN))
}
case pgDSN == "" && embedURL == "":
// disabled — fine
default:
logger.Error("BRAIN_PG_DSN and BRAIN_EMBED_URL must be set together")
os.Exit(1)
}
mcpToken := os.Getenv("BRAIN_MCP_TOKEN")
if mcpToken == "" {
logger.Error("BRAIN_MCP_TOKEN not set")
os.Exit(1)
}
ctx := context.Background()
if watchInterval > 0 {
watcher.Start(ctx, watcher.Config{
BrainDir: brainDir,
Interval: time.Duration(watchInterval) * time.Second,
Pipeline: pipelineCfg,
})
}
// Claude Code session ingestion (hyperguild#27 / infra#73 Track E.1).
// Off by default — explicitly opt in by setting CLAUDE_SESSIONS_DIR
// to the ~/.claude/projects path. Requires BRAIN_PG_DSN for the
// cursor table (resumable offsets across restarts).
if claudeDir := os.Getenv("CLAUDE_SESSIONS_DIR"); claudeDir != "" {
if pgDSN == "" {
logger.Error("CLAUDE_SESSIONS_DIR set but BRAIN_PG_DSN missing — claudewatcher needs the cursor table")
os.Exit(1)
}
cursorStore, cerr := claudewatcher.NewCursorStore(ctx, pgDSN)
if cerr != nil {
logger.Error("claudewatcher cursor init", "err", cerr)
os.Exit(1)
}
if cerr := cursorStore.Init(ctx); cerr != nil {
logger.Error("claudewatcher cursor migrate", "err", cerr)
os.Exit(1)
}
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
sink := &claudeSink{brainDir: brainDir, logger: logger}
go func() {
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
SessionsDir: claudeDir,
Host: host,
Interval: interval,
Sink: sink,
Cursors: cursorStore,
Logger: logger,
}); err != nil && err != context.Canceled {
logger.Error("claudewatcher exited", "err", err)
}
}()
logger.Info("claudewatcher started",
"sessions_dir", claudeDir, "host", host, "interval", interval)
}
if vectorStore != nil {
embedSyncInterval := envInt("BRAIN_EMBED_SYNC_INTERVAL", 300)
vectorstore.StartSync(ctx, brainDir, vectorStore,
embed.New(os.Getenv("BRAIN_EMBED_URL"),
envOr("BRAIN_EMBED_MODEL", "nomic-embed-text:latest")),
time.Duration(embedSyncInterval)*time.Second)
logger.Info("embed sync started", "interval_s", embedSyncInterval)
}
mux := http.NewServeMux()
mux.HandleFunc("/query", h.Query)
mux.HandleFunc("/write", h.Write)
mux.HandleFunc("POST /query", h.Query)
mux.HandleFunc("POST /write", h.Write)
mux.HandleFunc("POST /index", h.Index)
mux.HandleFunc("POST /ingest", h.Ingest)
mux.HandleFunc("POST /ingest-path", h.IngestPath)
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
mux.HandleFunc("GET /pass-rate", h.PassRate)
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
if err != nil {
logger.Error("build jwt validator", "err", err)
os.Exit(1)
}
if jwtValidator != nil {
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
}
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
// configured. Static-Bearer-only deployments leave this empty so
// clients never see an OAuth challenge.
var resourceMetadataURL string
if dexURL := os.Getenv("DEX_ISSUER_URL"); dexURL != "" {
resourceURL := os.Getenv("MCP_RESOURCE_URL")
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
chassisauth.ProtectedResourceHandler(resourceURL, dexURL))
if resourceURL != "" {
resourceMetadataURL = strings.TrimRight(resourceURL, "/") + "/.well-known/oauth-protected-resource"
}
}
mux.Handle("/mcp", chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv))
// Opt-in OAuth 2.0 client_credentials flow for claude.ai's custom-MCP
// integration UI, which has no static-Bearer field. Setting both
// OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET enables the token exchange;
// setting only one is misconfiguration → fail fast.
oauthID := os.Getenv("OAUTH_CLIENT_ID")
oauthSecret := os.Getenv("OAUTH_CLIENT_SECRET")
switch {
case oauthID != "" && oauthSecret != "":
issuer := os.Getenv("MCP_RESOURCE_URL")
if issuer == "" {
logger.Error("OAUTH_CLIENT_ID/SECRET set but MCP_RESOURCE_URL is empty; cannot derive issuer")
os.Exit(1)
}
mux.HandleFunc("GET /.well-known/oauth-authorization-server",
oauth.MetadataHandler(issuer))
mux.HandleFunc("POST /oauth/token", oauth.TokenHandler(oauth.TokenConfig{
ClientID: oauthID,
ClientSecret: oauthSecret,
AccessToken: mcpToken,
}))
logger.Info("oauth client_credentials enabled", "issuer", strings.TrimRight(issuer, "/"))
case oauthID == "" && oauthSecret == "":
// disabled — that's fine
default:
logger.Error("OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET must be set together")
os.Exit(1)
}
// /metrics — unauthenticated Prometheus endpoint. kube-prometheus-stack
// scrapes it via the ServiceMonitor in k3s/apps/supervisor/. The metrics
// middleware below wraps every other registered handler so it observes
// real request latency. /metrics itself is excluded from its own
// observation by registering it on the outer mux (post-wrap).
reg := metrics.New()
mux.HandleFunc("GET /metrics", reg.Handler())
logger.Info("metrics endpoint registered", "path", "/metrics")
addr := ":" + port
logger.Info("ingestion server starting", "addr", addr, "brain_dir", brainDir)
if err := http.ListenAndServe(addr, mux); err != nil {
watchIntervalLog := "disabled"
if watchInterval > 0 {
watchIntervalLog = fmt.Sprintf("%ds", watchInterval)
}
logger.Info("ingestion server starting",
"addr", addr,
"brain_dir", brainDir,
"llm_url", llmURL,
"llm_model", llmModel,
"chunk_size", chunkSize,
"watch_interval", watchIntervalLog,
"mcp_enabled", true,
)
if err := http.ListenAndServe(addr, reg.Middleware(mux)); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
}

View File

@@ -2,10 +2,30 @@ module github.com/mathiasbq/hyperguild/ingestion
go 1.26.1
require github.com/stretchr/testify v1.11.1
require (
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/stretchr/testify v1.11.1
)
require (
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.29.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,9 +1,54 @@
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -11,23 +11,43 @@ import (
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
)
// Handler serves the ingestion HTTP API.
type Handler struct {
brainDir string
logger *slog.Logger
pipeline pipeline.Config
embedStore vectorstore.Store
embedClient vectorstore.Embedder
}
// NewHandler constructs a Handler. brainDir is the absolute path to brain/.
func NewHandler(brainDir string, logger *slog.Logger) *Handler {
return &Handler{brainDir: brainDir, logger: logger}
func NewHandler(brainDir string, logger *slog.Logger, pipelineCfg pipeline.Config) *Handler {
if logger == nil {
logger = slog.Default()
}
return &Handler{brainDir: brainDir, logger: logger, pipeline: pipelineCfg}
}
// WithEmbedSync wires the optional vector store + embedder used by the
// POST /backfill-embeddings endpoint. Calling with either nil is a no-op.
func (h *Handler) WithEmbedSync(store vectorstore.Store, embedder vectorstore.Embedder) *Handler {
h.embedStore = store
h.embedClient = embedder
return h
}
type queryRequest struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
Wing string `json:"wing,omitempty"`
Hall string `json:"hall,omitempty"`
}
type writeRequest struct {
@@ -35,82 +55,441 @@ type writeRequest struct {
Filename string `json:"filename,omitempty"`
Type string `json:"type,omitempty"`
Domain string `json:"domain,omitempty"`
Wing string `json:"wing,omitempty"`
Hall string `json:"hall,omitempty"`
}
type ingestRequest struct {
Content string `json:"content"`
Source string `json:"source"`
DryRun bool `json:"dry_run"`
}
type ingestPathRequest struct {
Path string `json:"path"`
Source string `json:"source"`
DryRun bool `json:"dry_run"`
}
type ingestResponse struct {
Pages []string `json:"pages"`
Warnings []string `json:"warnings"`
}
// Query handles POST /query — full-text search across the brain wiki.
func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
var req queryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if strings.TrimSpace(req.Query) == "" {
http.Error(w, "query is required", http.StatusBadRequest)
writeError(w, http.StatusBadRequest, "query is required")
return
}
if req.Limit == 0 {
req.Limit = 5
}
results, err := search.Query(h.brainDir, req.Query, req.Limit)
results, err := search.Query(h.brainDir, search.QueryOptions{
Query: req.Query,
Limit: req.Limit,
Wing: req.Wing,
Hall: req.Hall,
})
if err != nil {
h.logger.Error("query failed", "err", err)
http.Error(w, "search error", http.StatusInternalServerError)
writeError(w, http.StatusInternalServerError, "search error")
return
}
writeJSON(w, map[string]any{"results": results})
}
// Write handles POST /write — write raw content to brain/raw/.
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
var req writeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if req.Content == "" {
http.Error(w, "content is required", http.StatusBadRequest)
return
// WriteNoteOptions configures how a brain note is written.
//
// When both Wing and Hall are non-empty, the note routes into the
// structured wiki at brain/wiki/<wing>/<hall>/<slug>.md and gets
// wing/hall/created_at injected into its YAML frontmatter.
//
// When either is empty, the note falls back to brain/knowledge/<filename>
// with optional type/domain frontmatter (legacy behaviour).
type WriteNoteOptions struct {
Content string
Filename string
Type string
Domain string
Wing string
Hall string
}
// WriteNote writes a markdown note into the brain. Returns the path
// relative to brainDir (forward-slashed). Filename traversal is rejected.
func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
if opts.Content == "" {
return "", fmt.Errorf("content is required")
}
filename := req.Filename
if opts.Wing != "" && opts.Hall != "" {
return writeHallNote(brainDir, opts)
}
if opts.Wing != "" || opts.Hall != "" {
return "", fmt.Errorf("wing and hall must be set together")
}
return writeLegacyNote(brainDir, opts)
}
// writeHallNote routes a note into brain/wiki/<wing>/<hall>/ and injects
// wing/hall/created_at frontmatter.
func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
slug := opts.Filename
if slug == "" {
slug = time.Now().UTC().Format("2006-01-02-150405") + "-auto"
}
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create hall dir: %w", err)
}
var fm strings.Builder
fm.WriteString("---\n")
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
if opts.Type != "" {
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
}
if opts.Domain != "" {
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
}
fm.WriteString("---\n")
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
return "", fmt.Errorf("write: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
return filepath.ToSlash(rel), nil
}
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
// callers that have not adopted the wing/hall taxonomy.
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
filename := opts.Filename
if filename == "" {
filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405"))
}
rawDir := filepath.Join(h.brainDir, "raw")
rawDir := filepath.Join(brainDir, "knowledge")
if err := os.MkdirAll(rawDir, 0o755); err != nil {
http.Error(w, "failed to create raw dir", http.StatusInternalServerError)
return
return "", fmt.Errorf("create raw dir: %w", err)
}
finalContent := req.Content
if req.Type != "" || req.Domain != "" {
finalContent := opts.Content
if opts.Type != "" || opts.Domain != "" {
var fm strings.Builder
fm.WriteString("---\n")
if req.Type != "" {
fmt.Fprintf(&fm, "type: %s\n", req.Type)
if opts.Type != "" {
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
}
if req.Domain != "" {
fmt.Fprintf(&fm, "domain: %s\n", req.Domain)
if opts.Domain != "" {
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
}
fm.WriteString("---\n")
finalContent = fm.String() + req.Content
finalContent = fm.String() + opts.Content
}
dest := filepath.Join(rawDir, filepath.Base(filename))
if strings.ContainsAny(filename, `/\`) {
return "", fmt.Errorf("invalid filename")
}
base := filepath.Base(filename)
if base == "." || base == ".." || base == "" {
return "", fmt.Errorf("invalid filename")
}
if !strings.HasSuffix(base, ".md") {
base += ".md"
}
dest := filepath.Join(rawDir, base)
if err := os.WriteFile(dest, []byte(finalContent), 0o644); err != nil {
return "", fmt.Errorf("write: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
return filepath.ToSlash(rel), nil
}
// Write handles POST /write — write raw content to brain/knowledge/.
func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
var req writeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
if err != nil {
h.logger.Error("write failed", "err", err)
http.Error(w, "write error", http.StatusInternalServerError)
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Wing != "" && req.Hall != "" {
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
h.logger.Warn("auto-index failed", "wing", req.Wing, "err", err)
}
}
writeJSON(w, map[string]string{"path": relPath})
}
// BackfillEmbeddings handles POST /backfill-embeddings — synchronously
// embeds every note under brain/wiki/ that's not yet in the vector
// store, and deletes rows for files no longer on disk.
func (h *Handler) BackfillEmbeddings(w http.ResponseWriter, r *http.Request) {
if h.embedStore == nil || h.embedClient == nil {
writeError(w, http.StatusServiceUnavailable,
"embeddings not configured (set BRAIN_PG_DSN and BRAIN_EMBED_URL)")
return
}
res, err := vectorstore.Sync(r.Context(), h.brainDir, h.embedStore, h.embedClient)
if err != nil {
h.logger.Error("backfill failed", "err", err)
writeError(w, http.StatusInternalServerError, "backfill error")
return
}
errStrs := make([]string, 0, len(res.Errors))
for _, e := range res.Errors {
errStrs = append(errStrs, e.Error())
}
writeJSON(w, map[string]any{
"added": res.Added,
"deleted": res.Deleted,
"errors": errStrs,
})
}
type indexRequest struct {
Wing string `json:"wing,omitempty"`
}
// Index handles POST /index — regenerate the _index.md MOC for one wing
// (when "wing" is set) or for every wing (when omitted).
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
var req indexRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
}
if req.Wing == "" {
if err := brain.BuildAllWingIndexes(h.brainDir); err != nil {
h.logger.Error("index all failed", "err", err)
writeError(w, http.StatusInternalServerError, "index error")
return
}
writeJSON(w, map[string]any{"status": "ok", "scope": "all"})
return
}
if err := brain.BuildWingIndex(h.brainDir, req.Wing); err != nil {
h.logger.Error("index failed", "wing", req.Wing, "err", err)
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, map[string]any{"status": "ok", "scope": req.Wing})
}
// Ingest handles POST /ingest — run the pipeline on provided content.
func (h *Handler) Ingest(w http.ResponseWriter, r *http.Request) {
var req ingestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if strings.TrimSpace(req.Content) == "" {
writeError(w, http.StatusBadRequest, "content is required")
return
}
if strings.TrimSpace(req.Source) == "" {
writeError(w, http.StatusBadRequest, "source is required")
return
}
rel, _ := filepath.Rel(h.brainDir, dest)
writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)})
result, err := pipeline.Run(r.Context(), h.pipeline, h.brainDir, req.Content, req.Source, req.DryRun)
if err != nil {
h.logger.Error("ingest failed", "source", req.Source, "err", err)
writeError(w, http.StatusInternalServerError, "ingest error")
return
}
pages := result.Pages
if pages == nil {
pages = []string{}
}
warnings := result.Warnings
if warnings == nil {
warnings = []string{}
}
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
}
// supportedExtensions lists file extensions that IngestPath will process.
var supportedExtensions = map[string]bool{
".md": true,
".txt": true,
".pdf": true,
}
// IngestPath handles POST /ingest-path — ingest a file or directory.
func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
var req ingestPathRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if strings.TrimSpace(req.Path) == "" {
writeError(w, http.StatusBadRequest, "path is required")
return
}
info, err := os.Stat(req.Path)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("path not accessible: %v", err))
return
}
var allPages []string
var allWarnings []string
if info.IsDir() {
err = filepath.WalkDir(req.Path, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if !supportedExtensions[ext] {
return nil
}
content, readErr := extract.Text(path)
if readErr != nil {
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
return nil
}
source := req.Source
if source == "" {
source = filepath.Base(path)
}
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, content, source, req.DryRun)
if runErr != nil {
allWarnings = append(allWarnings, fmt.Sprintf("ingest %s: %v", path, runErr))
return nil
}
allPages = append(allPages, result.Pages...)
allWarnings = append(allWarnings, result.Warnings...)
return nil
})
if err != nil {
h.logger.Error("walk dir failed", "path", req.Path, "err", err)
writeError(w, http.StatusInternalServerError, fmt.Sprintf("walk error: %v", err))
return
}
} else {
ext := strings.ToLower(filepath.Ext(req.Path))
if !supportedExtensions[ext] {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
return
}
content, readErr := extract.Text(req.Path)
if readErr != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
return
}
source := req.Source
if source == "" {
source = filepath.Base(req.Path)
}
result, runErr := pipeline.Run(r.Context(), h.pipeline, h.brainDir, content, source, req.DryRun)
if runErr != nil {
h.logger.Error("ingest-path failed", "path", req.Path, "err", runErr)
writeError(w, http.StatusInternalServerError, "ingest error")
return
}
allPages = result.Pages
allWarnings = result.Warnings
}
if allPages == nil {
allPages = []string{}
}
if allWarnings == nil {
allWarnings = []string{}
}
writeJSON(w, ingestResponse{Pages: allPages, Warnings: allWarnings})
}
type ingestRawRequest struct {
Source string `json:"source"`
Pages []pipeline.RawPage `json:"pages"`
DryRun bool `json:"dry_run"`
}
// IngestRaw handles POST /ingest-raw — run the pipeline on pre-parsed RawPages,
// skipping the LLM extraction step. Use when the caller has already produced
// structured page data (e.g. from a more capable model or manual curation).
func (h *Handler) IngestRaw(w http.ResponseWriter, r *http.Request) {
var req ingestRawRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if strings.TrimSpace(req.Source) == "" {
writeError(w, http.StatusBadRequest, "source is required")
return
}
if len(req.Pages) == 0 {
writeError(w, http.StatusBadRequest, "pages is required and must be non-empty")
return
}
result, err := pipeline.RunRaw(h.brainDir, req.Source, req.Pages, req.DryRun)
if err != nil {
h.logger.Error("ingest-raw failed", "source", req.Source, "err", err)
writeError(w, http.StatusInternalServerError, "ingest error")
return
}
pages := result.Pages
if pages == nil {
pages = []string{}
}
warnings := result.Warnings
if warnings == nil {
warnings = []string{}
}
writeJSON(w, ingestResponse{Pages: pages, Warnings: warnings})
}
// BackfillRefs handles POST /backfill-refs — injects source back-references
// into all concept and entity pages based on existing wiki/sources/ pages.
func (h *Handler) BackfillRefs(w http.ResponseWriter, r *http.Request) {
n, err := pipeline.BackfillRefs(r.Context(), h.brainDir)
if err != nil {
h.logger.Error("backfill-refs failed", "err", err)
writeError(w, http.StatusInternalServerError, "backfill error")
return
}
writeJSON(w, map[string]int{"updated": n})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) //nolint:errcheck
}
func writeError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg}) //nolint:errcheck
}

View File

@@ -3,6 +3,7 @@ package api_test
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
@@ -12,25 +13,43 @@ import (
"strings"
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
)
// stubComplete returns a fixed JSON RawPage so tests never call a real LLM.
func stubComplete(_ context.Context, _, _ string) (string, error) {
return `[{"title":"Test Source","type":"source","subtype":"article","content":"## Summary\n\nSome content here.\n"}]`, nil
}
func stubPipelineCfg() pipeline.Config {
return pipeline.Config{
Complete: stubComplete,
ChunkSize: 0,
Schema: "# Test Schema\nwiki/sources/, wiki/concepts/, wiki/entities/",
}
}
func setup(t *testing.T) (string, *api.Handler) {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(dir, "wiki", "concepts", "tdd.md"),
filepath.Join(dir, "knowledge", "tdd.md"),
[]byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"),
0o644,
))
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
return dir, api.NewHandler(dir, logger)
return dir, api.NewHandler(dir, logger, stubPipelineCfg())
}
// ---------------------------------------------------------------------------
// Existing tests (Write / Query)
// ---------------------------------------------------------------------------
func TestQuery_ReturnsResults(t *testing.T) {
_, h := setup(t)
body, _ := json.Marshal(map[string]any{"query": "test driven", "limit": 5})
@@ -46,7 +65,7 @@ func TestQuery_ReturnsResults(t *testing.T) {
assert.NotEmpty(t, results)
}
func TestWrite_CreatesRawFile(t *testing.T) {
func TestWrite_CreatesKnowledgeFile(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"content": "# Test note\n\nSome content.",
@@ -62,8 +81,7 @@ func TestWrite_CreatesRawFile(t *testing.T) {
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotEmpty(t, resp["path"])
written := filepath.Join(dir, "raw", "test-note.md")
content, err := os.ReadFile(written)
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "test-note.md"))
require.NoError(t, err)
assert.Contains(t, string(content), "Some content.")
}
@@ -93,7 +111,7 @@ func TestWrite_IncludesFrontmatterWhenTypeProvided(t *testing.T) {
h.Write(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
content, err := os.ReadFile(filepath.Join(dir, "raw", "typed-note.md"))
content, err := os.ReadFile(filepath.Join(dir, "knowledge", "typed-note.md"))
require.NoError(t, err)
assert.Contains(t, string(content), "type: concept")
assert.Contains(t, string(content), "domain: software")
@@ -109,7 +127,206 @@ func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
h.Write(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
entries, _ := os.ReadDir(filepath.Join(dir, "raw"))
assert.Len(t, entries, 1)
assert.True(t, strings.HasSuffix(entries[0].Name(), ".md"))
entries, _ := os.ReadDir(filepath.Join(dir, "knowledge"))
// +1 because setup already wrote tdd.md
assert.Len(t, entries, 2)
assert.True(t, strings.HasSuffix(entries[1].Name(), ".md"))
}
// ---------------------------------------------------------------------------
// POST /ingest
// ---------------------------------------------------------------------------
func TestIngest_Validation(t *testing.T) {
cases := []struct {
name string
body map[string]any
}{
{"missing content", map[string]any{"source": "test-source"}},
{"missing source", map[string]any{"content": "some content"}},
{"whitespace content", map[string]any{"content": " ", "source": "test-source"}},
{"whitespace source", map[string]any{"content": "some content", "source": " "}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, h := setup(t)
body, _ := json.Marshal(tc.body)
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Ingest(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
}
func TestIngest_Success(t *testing.T) {
_, h := setup(t)
body, _ := json.Marshal(map[string]any{
"content": "some content about shape-up methodology",
"source": "shape-up-book",
"dry_run": true,
})
req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.Ingest(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
pages, ok := resp["pages"]
require.True(t, ok, "response must have pages field")
pagesSlice, ok := pages.([]any)
require.True(t, ok, "pages must be an array")
assert.NotEmpty(t, pagesSlice)
}
// ---------------------------------------------------------------------------
// POST /ingest-path
// ---------------------------------------------------------------------------
func TestIngestPath_MissingPath(t *testing.T) {
_, h := setup(t)
body, _ := json.Marshal(map[string]any{"source": "test-source"})
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.IngestPath(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestIngestPath_File(t *testing.T) {
_, h := setup(t)
// Create a temp file with content
dir := t.TempDir()
f := filepath.Join(dir, "doc.md")
require.NoError(t, os.WriteFile(f, []byte("# Hello\nThis is markdown content."), 0o644))
body, _ := json.Marshal(map[string]any{
"path": f,
"source": "test-doc",
"dry_run": true,
})
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.IngestPath(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
pages, ok := resp["pages"]
require.True(t, ok, "response must have pages field")
pagesSlice, ok := pages.([]any)
require.True(t, ok, "pages must be an array")
assert.NotEmpty(t, pagesSlice)
}
// ---------------------------------------------------------------------------
// POST /ingest-raw
// ---------------------------------------------------------------------------
func TestIngestRaw_Validation(t *testing.T) {
cases := []struct {
name string
body map[string]any
}{
{"missing source", map[string]any{"pages": []any{map[string]any{"title": "X", "type": "concept", "content": "x"}}}},
{"missing pages", map[string]any{"source": "test-source"}},
{"empty pages", map[string]any{"source": "test-source", "pages": []any{}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, h := setup(t)
body, _ := json.Marshal(tc.body)
req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.IngestRaw(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
}
func TestIngestRaw_Success(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"source": "test-article",
"pages": []any{
map[string]any{"title": "Test Article", "type": "source", "subtype": "article", "domain": "Testing", "content": "## Summary\n\nThis is a test article about [[Test Concept]].\n"},
map[string]any{"title": "Test Concept", "type": "concept", "domain": "Testing", "content": "A concept for testing.\n"},
},
})
req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.IngestRaw(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
pages := resp["pages"].([]any)
assert.Len(t, pages, 2)
// Verify files were written
sourcePath := filepath.Join(dir, "wiki", "sources", "test-article.md")
assert.FileExists(t, sourcePath)
conceptPath := filepath.Join(dir, "wiki", "concepts", "test-concept.md")
assert.FileExists(t, conceptPath)
}
func TestIngestRaw_DryRun(t *testing.T) {
dir, h := setup(t)
body, _ := json.Marshal(map[string]any{
"source": "dry-run-test",
"pages": []any{
map[string]any{"title": "Dry Run Source", "type": "source", "subtype": "article", "content": "Content."},
},
"dry_run": true,
})
req := httptest.NewRequest(http.MethodPost, "/ingest-raw", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.IngestRaw(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
pages := resp["pages"].([]any)
assert.NotEmpty(t, pages)
// Verify no files were written
sourcePath := filepath.Join(dir, "wiki", "sources", "dry-run-test.md")
assert.NoFileExists(t, sourcePath)
}
func TestIngestPath_Directory(t *testing.T) {
_, h := setup(t)
// Create a temp dir with one .md file
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes\nSome notes."), 0o644))
body, _ := json.Marshal(map[string]any{
"path": dir,
"dry_run": true,
})
req := httptest.NewRequest(http.MethodPost, "/ingest-path", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.IngestPath(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
pages, ok := resp["pages"]
require.True(t, ok, "response must have pages field")
pagesSlice, ok := pages.([]any)
require.True(t, ok, "pages must be an array")
assert.NotEmpty(t, pagesSlice)
}

View File

@@ -0,0 +1,140 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type passRateResponse struct {
Skill string `json:"skill"`
Window string `json:"window"`
Pass int `json:"pass"`
Fail int `json:"fail"`
Skip int `json:"skip"`
Total int `json:"total"`
PassRate *float64 `json:"pass_rate"`
}
// PassRate handles GET /pass-rate?skill=X&window=Y.
// Walks brainDir/sessions/*.jsonl, filters by skill name and timestamp,
// returns aggregated counts and pass rate.
func (h *Handler) PassRate(w http.ResponseWriter, r *http.Request) {
skill := r.URL.Query().Get("skill")
if skill == "" {
writeError(w, http.StatusBadRequest, "skill is required")
return
}
windowStr := r.URL.Query().Get("window")
if windowStr == "" {
windowStr = "7d"
}
window, err := parseWindow(windowStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid window: "+err.Error())
return
}
cutoff := time.Now().UTC().Add(-window)
pass, fail, skip := 0, 0, 0
sessionsDir := filepath.Join(h.brainDir, "sessions")
entries, err := os.ReadDir(sessionsDir)
if err != nil && !os.IsNotExist(err) {
writeError(w, http.StatusInternalServerError, "read sessions dir: "+err.Error())
return
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
continue
}
body, err := os.ReadFile(filepath.Join(sessionsDir, entry.Name()))
if err != nil {
continue // skip unreadable files
}
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var rec struct {
Timestamp string `json:"timestamp"`
Skill string `json:"skill"`
FinalStatus string `json:"final_status"`
}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
continue // malformed — skip
}
if rec.Skill != skill {
continue
}
ts, err := time.Parse(time.RFC3339, rec.Timestamp)
if err != nil {
continue
}
if ts.Before(cutoff) {
continue
}
switch normalizeStatus(rec.FinalStatus) {
case "pass":
pass++
case "fail":
fail++
case "skip":
skip++
}
}
}
total := pass + fail + skip
resp := passRateResponse{
Skill: skill,
Window: windowStr,
Pass: pass,
Fail: fail,
Skip: skip,
Total: total,
}
if pass+fail > 0 {
rate := float64(pass) / float64(pass+fail)
resp.PassRate = &rate
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// normalizeStatus maps both new (pass/fail/skip) and legacy (ok/error/skipped)
// vocabularies to the canonical pass/fail/skip set. Unknown values are treated
// as skip for safety.
func normalizeStatus(s string) string {
switch s {
case "pass", "ok":
return "pass"
case "fail", "error":
return "fail"
case "skip", "skipped":
return "skip"
default:
return "skip"
}
}
// parseWindow accepts Go-style durations plus "Nd" for days.
func parseWindow(s string) (time.Duration, error) {
if strings.HasSuffix(s, "d") {
// Replace "d" with "h" * 24
days := strings.TrimSuffix(s, "d")
d, err := time.ParseDuration(days + "h")
if err != nil {
return 0, err
}
return d * 24, nil
}
return time.ParseDuration(s)
}

View File

@@ -0,0 +1,172 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeSession writes one or more JSONL entries to <dir>/sessions/<sessionID>.jsonl.
// The handler scans <brainDir>/sessions/, so test fixtures must mirror that layout.
func writeSession(t *testing.T, dir, sessionID string, entries ...string) {
t.Helper()
sessionsDir := filepath.Join(dir, "sessions")
require.NoError(t, os.MkdirAll(sessionsDir, 0o755))
path := filepath.Join(sessionsDir, sessionID+".jsonl")
body := ""
for _, e := range entries {
body += e + "\n"
}
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
}
func TestPassRate_HappyPath(t *testing.T) {
dir := t.TempDir()
now := time.Now().UTC()
recent := now.Add(-1 * time.Hour).Format(time.RFC3339)
writeSession(t, dir, "s1",
`{"timestamp":"`+recent+`","skill":"tdd","phase":"red","final_status":"pass"}`,
`{"timestamp":"`+recent+`","skill":"tdd","phase":"green","final_status":"pass"}`,
`{"timestamp":"`+recent+`","skill":"tdd","phase":"refactor","final_status":"fail"}`,
)
writeSession(t, dir, "s2",
`{"timestamp":"`+recent+`","skill":"code-review","phase":"review","final_status":"pass"}`,
)
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
w := httptest.NewRecorder()
h.PassRate(w, req)
resp := w.Result()
require.Equal(t, http.StatusOK, resp.StatusCode)
var got passRateResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "tdd", got.Skill)
assert.Equal(t, "24h", got.Window)
assert.Equal(t, 2, got.Pass)
assert.Equal(t, 1, got.Fail)
assert.Equal(t, 0, got.Skip)
assert.Equal(t, 3, got.Total)
require.NotNil(t, got.PassRate)
assert.InDelta(t, 0.6667, *got.PassRate, 0.001)
}
func TestPassRate_LegacyVocabulary(t *testing.T) {
dir := t.TempDir()
now := time.Now().UTC().Format(time.RFC3339)
writeSession(t, dir, "s1",
`{"timestamp":"`+now+`","skill":"tdd","final_status":"ok"}`,
`{"timestamp":"`+now+`","skill":"tdd","final_status":"error"}`,
`{"timestamp":"`+now+`","skill":"tdd","final_status":"skipped"}`,
)
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
w := httptest.NewRecorder()
h.PassRate(w, req)
var got passRateResponse
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
assert.Equal(t, 1, got.Pass, "ok→pass")
assert.Equal(t, 1, got.Fail, "error→fail")
assert.Equal(t, 1, got.Skip, "skipped→skip")
}
func TestPassRate_OutsideWindow_Excluded(t *testing.T) {
dir := t.TempDir()
old := time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
recent := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
writeSession(t, dir, "s1",
`{"timestamp":"`+old+`","skill":"tdd","final_status":"pass"}`,
`{"timestamp":"`+recent+`","skill":"tdd","final_status":"pass"}`,
)
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
w := httptest.NewRecorder()
h.PassRate(w, req)
var got passRateResponse
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
assert.Equal(t, 1, got.Pass)
assert.Equal(t, 1, got.Total)
}
func TestPassRate_NoData_ReturnsZerosAndNullRate(t *testing.T) {
dir := t.TempDir()
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
w := httptest.NewRecorder()
h.PassRate(w, req)
var got passRateResponse
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
assert.Equal(t, 0, got.Pass)
assert.Equal(t, 0, got.Fail)
assert.Equal(t, 0, got.Skip)
assert.Equal(t, 0, got.Total)
assert.Nil(t, got.PassRate, "pass_rate must be null when pass+fail == 0")
}
func TestPassRate_DefaultsTo7d(t *testing.T) {
dir := t.TempDir()
now := time.Now().UTC().Format(time.RFC3339)
writeSession(t, dir, "s1", `{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`)
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd", nil) // no window
w := httptest.NewRecorder()
h.PassRate(w, req)
var got passRateResponse
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
assert.Equal(t, "7d", got.Window)
assert.Equal(t, 1, got.Pass)
}
func TestPassRate_MissingSkill_ReturnsBadRequest(t *testing.T) {
dir := t.TempDir()
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate", nil)
w := httptest.NewRecorder()
h.PassRate(w, req)
assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
}
func TestPassRate_BadWindow_ReturnsBadRequest(t *testing.T) {
dir := t.TempDir()
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=foo", nil)
w := httptest.NewRecorder()
h.PassRate(w, req)
assert.Equal(t, http.StatusBadRequest, w.Result().StatusCode)
}
func TestPassRate_MalformedLine_Skipped(t *testing.T) {
dir := t.TempDir()
now := time.Now().UTC().Format(time.RFC3339)
writeSession(t, dir, "s1",
`{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`,
`not valid json`,
`{"timestamp":"`+now+`","skill":"tdd","final_status":"pass"}`,
)
h := &Handler{brainDir: dir}
req := httptest.NewRequest(http.MethodGet, "/pass-rate?skill=tdd&window=24h", nil)
w := httptest.NewRecorder()
h.PassRate(w, req)
var got passRateResponse
require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&got))
assert.Equal(t, 2, got.Pass, "the malformed line is silently skipped")
}

View File

@@ -0,0 +1,161 @@
package brain
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// noteEntry is one row in a Wing _index.md.
type noteEntry struct {
Hall string
Slug string
Title string
Created string
}
// BuildWingIndex regenerates brain/wiki/<wing>/_index.md as a Map of
// Content listing every note in that wing with its Hall and creation
// date. Returns nil if the wing directory does not exist.
func BuildWingIndex(brainDir, wing string) error {
w := Sanitise(wing)
if w == "" {
return fmt.Errorf("invalid wing %q", wing)
}
wingDir := filepath.Join(brainDir, "wiki", w)
if _, err := os.Stat(wingDir); os.IsNotExist(err) {
return nil
} else if err != nil {
return fmt.Errorf("stat wing: %w", err)
}
entries, err := collectWingEntries(wingDir)
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Hall != entries[j].Hall {
return entries[i].Hall < entries[j].Hall
}
return entries[i].Slug < entries[j].Slug
})
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", w)
b.WriteString("| Hall | Note | Created |\n")
b.WriteString("|------|------|---------|\n")
for _, e := range entries {
fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created)
}
dest := filepath.Join(wingDir, "_index.md")
return os.WriteFile(dest, []byte(b.String()), 0o644)
}
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
func BuildAllWingIndexes(brainDir string) error {
wikiDir := filepath.Join(brainDir, "wiki")
ents, err := os.ReadDir(wikiDir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read wiki: %w", err)
}
for _, e := range ents {
if !e.IsDir() {
continue
}
if err := BuildWingIndex(brainDir, e.Name()); err != nil {
return fmt.Errorf("index %s: %w", e.Name(), err)
}
}
return nil
}
func collectWingEntries(wingDir string) ([]noteEntry, error) {
var out []noteEntry
ents, err := os.ReadDir(wingDir)
if err != nil {
return nil, fmt.Errorf("read wing: %w", err)
}
for _, hallEnt := range ents {
if !hallEnt.IsDir() {
continue
}
hall := hallEnt.Name()
if !IsValidHall(hall) {
continue
}
hallDir := filepath.Join(wingDir, hall)
notes, err := os.ReadDir(hallDir)
if err != nil {
return nil, fmt.Errorf("read hall %s: %w", hall, err)
}
for _, n := range notes {
if n.IsDir() || !strings.HasSuffix(n.Name(), ".md") || n.Name() == "_index.md" {
continue
}
slug := strings.TrimSuffix(n.Name(), ".md")
full := filepath.Join(hallDir, n.Name())
title, created := readTitleAndCreated(full, slug)
out = append(out, noteEntry{Hall: hall, Slug: slug, Title: title, Created: created})
}
}
return out, nil
}
// readTitleAndCreated reads YAML frontmatter for title + created_at; falls
// back to slug and file mtime when absent.
func readTitleAndCreated(path, slug string) (string, string) {
f, err := os.Open(path)
if err != nil {
return slug, ""
}
defer func() { _ = f.Close() }()
title, created := "", ""
scanner := bufio.NewScanner(f)
inFrontmatter := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "---" {
if !inFrontmatter {
inFrontmatter = true
continue
}
break
}
if !inFrontmatter {
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
v := strings.Trim(strings.TrimSpace(val), `"'`)
switch strings.TrimSpace(key) {
case "title":
title = v
case "created_at":
if t, err := time.Parse(time.RFC3339, v); err == nil {
created = t.UTC().Format("2006-01-02")
} else {
created = v
}
}
}
if title == "" {
title = strings.ReplaceAll(slug, "-", " ")
}
if created == "" {
if info, err := os.Stat(path); err == nil {
created = info.ModTime().UTC().Format("2006-01-02")
}
}
return title, created
}

View File

@@ -0,0 +1,85 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildWingIndex(t *testing.T) {
dir := t.TempDir()
for _, p := range []struct{ rel, body string }{
{"wiki/jepa-fx/decisions/val-vol.md", "---\ntitle: Val Vol R2\ncreated_at: 2026-05-06T10:00:00Z\n---\nbody\n"},
{"wiki/jepa-fx/facts/architecture.md", "---\ntitle: Architecture\ncreated_at: 2026-05-04T10:00:00Z\n---\nbody\n"},
{"wiki/jepa-fx/sources/paper.md", "---\n---\nbody\n"},
} {
full := filepath.Join(dir, p.rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
}
require.NoError(t, brain.BuildWingIndex(dir, "jepa-fx"))
got, err := os.ReadFile(filepath.Join(dir, "wiki", "jepa-fx", "_index.md"))
require.NoError(t, err)
s := string(got)
assert.Contains(t, s, "# jepa-fx")
assert.Contains(t, s, "| Hall | Note | Created |")
assert.Contains(t, s, "| decisions | [Val Vol R2](decisions/val-vol.md) | 2026-05-06 |")
assert.Contains(t, s, "| facts | [Architecture](facts/architecture.md) | 2026-05-04 |")
assert.Contains(t, s, "| sources | [paper](sources/paper.md) |")
// Halls sorted alphabetically.
assert.Less(t, indexOf(s, "decisions"), indexOf(s, "facts"))
assert.Less(t, indexOf(s, "facts"), indexOf(s, "sources"))
}
func TestBuildWingIndex_SkipsInvalidHalls(t *testing.T) {
dir := t.TempDir()
wingDir := filepath.Join(dir, "wiki", "jepa-fx")
require.NoError(t, os.MkdirAll(filepath.Join(wingDir, "garbage"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(wingDir, "garbage", "x.md"), []byte("x"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(wingDir, "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(wingDir, "facts", "y.md"), []byte("y"), 0o644))
require.NoError(t, brain.BuildWingIndex(dir, "jepa-fx"))
got, err := os.ReadFile(filepath.Join(wingDir, "_index.md"))
require.NoError(t, err)
s := string(got)
assert.Contains(t, s, "facts")
assert.NotContains(t, s, "garbage")
}
func TestBuildAllWingIndexes(t *testing.T) {
dir := t.TempDir()
for _, p := range []struct{ rel, body string }{
{"wiki/a/facts/x.md", "x"},
{"wiki/b/facts/y.md", "y"},
} {
full := filepath.Join(dir, p.rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(p.body), 0o644))
}
require.NoError(t, brain.BuildAllWingIndexes(dir))
_, err := os.Stat(filepath.Join(dir, "wiki", "a", "_index.md"))
require.NoError(t, err)
_, err = os.Stat(filepath.Join(dir, "wiki", "b", "_index.md"))
require.NoError(t, err)
}
func TestBuildWingIndex_NoWingDir(t *testing.T) {
dir := t.TempDir()
require.NoError(t, brain.BuildWingIndex(dir, "ghost"))
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

View File

@@ -0,0 +1,70 @@
// Package brain provides the wing/hall path taxonomy used by the brain
// wiki layout. A note's canonical location is
// brain/wiki/<wing>/<hall>/<slug>.md, where Wing is a free-form topic
// domain and Hall is one of a closed vocabulary of memory types.
package brain
import (
"fmt"
"path/filepath"
"strings"
)
// ValidHalls is the closed vocabulary of hall names. A hall captures the
// memory type of a note within any wing.
var ValidHalls = map[string]bool{
"facts": true,
"decisions": true,
"failures": true,
"hypotheses": true,
"sources": true,
}
// IsValidHall reports whether h is in the closed Hall vocabulary.
func IsValidHall(h string) bool {
return ValidHalls[h]
}
// NotePath resolves the canonical filesystem path for a note given a
// wing, hall, and slug. Returns an error if hall is not in ValidHalls
// or if wing/slug sanitise to empty strings.
//
// The returned path is brain/wiki/<wing>/<hall>/<slug>.md with all
// segments sanitised: lowercased, alphanumerics and hyphens only.
func NotePath(brainDir, wing, hall, slug string) (string, error) {
if !IsValidHall(hall) {
return "", fmt.Errorf("invalid hall %q: must be one of facts/decisions/failures/hypotheses/sources", hall)
}
w := Sanitise(wing)
if w == "" {
return "", fmt.Errorf("invalid wing %q: must contain at least one alphanumeric character", wing)
}
s := Sanitise(strings.TrimSuffix(slug, ".md"))
if s == "" {
return "", fmt.Errorf("invalid slug %q: must contain at least one alphanumeric character", slug)
}
return filepath.Join(brainDir, "wiki", w, hall, s+".md"), nil
}
// Sanitise lowercases s and keeps only [a-z0-9-], collapsing any other
// character (including path separators) to a hyphen. Leading/trailing
// hyphens and runs of hyphens are collapsed.
func Sanitise(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
prevHyphen := true
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prevHyphen = false
case r == '-' || r == '_' || r == ' ' || r == '/' || r == '\\' || r == '.':
if !prevHyphen {
b.WriteByte('-')
prevHyphen = true
}
}
}
out := b.String()
return strings.Trim(out, "-")
}

View File

@@ -0,0 +1,73 @@
package brain_test
import (
"path/filepath"
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNotePath_Valid(t *testing.T) {
got, err := brain.NotePath("/b", "jepa-fx", "decisions", "val-vol-r2")
require.NoError(t, err)
assert.Equal(t, filepath.Join("/b", "wiki", "jepa-fx", "decisions", "val-vol-r2.md"), got)
}
func TestNotePath_StripsMdSuffix(t *testing.T) {
got, err := brain.NotePath("/b", "x", "facts", "note.md")
require.NoError(t, err)
assert.Equal(t, filepath.Join("/b", "wiki", "x", "facts", "note.md"), got)
}
func TestNotePath_SanitisesWingAndSlug(t *testing.T) {
got, err := brain.NotePath("/b", "Jepa FX!", "facts", "Val Vol R2")
require.NoError(t, err)
assert.Equal(t, filepath.Join("/b", "wiki", "jepa-fx", "facts", "val-vol-r2.md"), got)
}
func TestNotePath_RejectsInvalidHall(t *testing.T) {
_, err := brain.NotePath("/b", "x", "garbage", "y")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid hall")
}
func TestNotePath_RejectsEmptyWing(t *testing.T) {
_, err := brain.NotePath("/b", "!!!", "facts", "y")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid wing")
}
func TestNotePath_RejectsEmptySlug(t *testing.T) {
_, err := brain.NotePath("/b", "x", "facts", "!!!")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid slug")
}
func TestSanitise(t *testing.T) {
cases := map[string]string{
"Jepa-FX": "jepa-fx",
" foo bar ": "foo-bar",
"Val/Vol\\R2.md": "val-vol-r2-md",
"!!!": "",
"___leading": "leading",
"trailing___": "trailing",
"multi---hyphen": "multi-hyphen",
"UPPER 123 mixed": "upper-123-mixed",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
assert.Equal(t, want, brain.Sanitise(in))
})
}
}
func TestIsValidHall(t *testing.T) {
for _, h := range []string{"facts", "decisions", "failures", "hypotheses", "sources"} {
assert.True(t, brain.IsValidHall(h), h)
}
for _, h := range []string{"", "Facts", "facts ", "rooms", "concepts", "entities"} {
assert.False(t, brain.IsValidHall(h), h)
}
}

View File

@@ -0,0 +1,286 @@
package brain
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// seeAlsoHeader is the markdown heading used to group cross-wing links.
const seeAlsoHeader = "## See also"
// TunnelCandidate is a cross-wing match surfaced by DetectTunnels. It is
// not yet a written link — the caller decides whether confidence is high
// enough to commit it via WriteTunnel.
type TunnelCandidate struct {
// TargetPath is the candidate note's path relative to brainDir
// (forward-slashed), e.g. "wiki/hyperguild/decisions/routing.md".
TargetPath string
// MatchedTerm is the title that matched in the source content.
MatchedTerm string
// Exact is true when the match was a case-insensitive whole-token
// hit on the target's frontmatter title. Fuzzy matches (substring
// only) are flagged Exact=false and should not be auto-written.
Exact bool
}
// DetectTunnels scans brain/wiki/ for notes whose title appears in
// content. Returns one TunnelCandidate per matching note. Exact is true
// when content contains the title as a whole-word case-insensitive
// token; false when only a substring matched (caller treats these as
// fuzzy and should not auto-write them).
//
// A note's title is read from YAML frontmatter `title:`; failing that,
// the filename slug (sans `.md`, hyphens → spaces) is used.
func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
wikiDir := filepath.Join(brainDir, "wiki")
if _, err := os.Stat(wikiDir); os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("stat wiki: %w", err)
}
lowerContent := strings.ToLower(content)
var out []TunnelCandidate
err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
return nil
}
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
needle := strings.ToLower(strings.TrimSpace(title))
if needle == "" {
return nil
}
idx := strings.Index(lowerContent, needle)
if idx == -1 {
return nil
}
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return err
}
out = append(out, TunnelCandidate{
TargetPath: filepath.ToSlash(rel),
MatchedTerm: title,
Exact: isWholeWord(lowerContent, idx, len(needle)),
})
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// isWholeWord reports whether the substring at [idx, idx+n) in s is
// bounded by non-alphanumeric characters (or string edges).
func isWholeWord(s string, idx, n int) bool {
left := idx == 0 || !isWordByte(s[idx-1])
right := idx+n == len(s) || !isWordByte(s[idx+n])
return left && right
}
func isWordByte(b byte) bool {
return (b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') ||
(b >= '0' && b <= '9')
}
// AutoTunnel runs DetectTunnels against content and, for each
// candidate, either writes a bidirectional tunnel (when the match is
// exact and in a different wing) or stages it for human review in
// brain/raw/tunnel-candidates-<YYYY-MM-DD>.md.
//
// sourcePath is the note that originated the content — used to skip
// self-matches and same-wing tunnels. Errors writing individual
// tunnels are recorded into the candidates file but never abort the
// rest of the scan; the caller's primary write has already succeeded
// and auto-linking is best-effort.
func AutoTunnel(brainDir, sourcePath, content string) error {
srcWing, err := wingOf(sourcePath)
if err != nil {
return err
}
candidates, err := DetectTunnels(brainDir, content)
if err != nil {
return err
}
var fuzzy []TunnelCandidate
for _, c := range candidates {
if c.TargetPath == sourcePath {
continue
}
tgtWing, err := wingOf(c.TargetPath)
if err != nil || tgtWing == srcWing {
continue
}
if !c.Exact {
fuzzy = append(fuzzy, c)
continue
}
if err := WriteTunnel(brainDir, sourcePath, c.TargetPath); err != nil {
fuzzy = append(fuzzy, c)
}
}
return logFuzzyCandidates(brainDir, sourcePath, fuzzy)
}
// logFuzzyCandidates appends one row per candidate to
// brain/raw/tunnel-candidates-<YYYY-MM-DD>.md, creating the file with a
// header on first write of the day. No-op when the candidate list is empty.
func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error {
if len(cs) == 0 {
return nil
}
rawDir := filepath.Join(brainDir, "raw")
if err := os.MkdirAll(rawDir, 0o755); err != nil {
return err
}
stamp := time.Now().UTC().Format("2006-01-02")
path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md")
existed := fileExists(path)
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if !existed {
if _, err := f.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n"); err != nil {
return err
}
}
for _, c := range cs {
line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
if _, err := f.WriteString(line); err != nil {
return err
}
}
return nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
// WriteTunnel appends a bidirectional wikilink between sourcePath and
// targetPath under a `## See also` section in each note. Paths are
// relative to brainDir (forward-slashed), e.g. wiki/<wing>/<hall>/<slug>.md.
//
// Idempotent: re-calling with the same pair does not duplicate links or
// section headers. Rejects same-wing pairs (a tunnel is by definition
// cross-wing) and missing notes.
func WriteTunnel(brainDir, sourcePath, targetPath string) error {
srcWing, err := wingOf(sourcePath)
if err != nil {
return fmt.Errorf("source: %w", err)
}
tgtWing, err := wingOf(targetPath)
if err != nil {
return fmt.Errorf("target: %w", err)
}
if srcWing == tgtWing {
return fmt.Errorf("tunnel must cross wings; got both in %q", srcWing)
}
srcFull := filepath.Join(brainDir, filepath.FromSlash(sourcePath))
tgtFull := filepath.Join(brainDir, filepath.FromSlash(targetPath))
if _, err := os.Stat(srcFull); err != nil {
return fmt.Errorf("source note: %w", err)
}
if _, err := os.Stat(tgtFull); err != nil {
return fmt.Errorf("target note: %w", err)
}
if err := appendSeeAlso(srcFull, wikilinkOf(targetPath)); err != nil {
return fmt.Errorf("update source: %w", err)
}
if err := appendSeeAlso(tgtFull, wikilinkOf(sourcePath)); err != nil {
return fmt.Errorf("update target: %w", err)
}
return nil
}
// wikilinkOf turns "wiki/<wing>/<hall>/<slug>.md" into "<wing>/<hall>/<slug>"
// for use inside `[[...]]`.
func wikilinkOf(relPath string) string {
p := strings.TrimSuffix(relPath, ".md")
p = strings.TrimPrefix(p, "wiki/")
return p
}
// wingOf extracts the wing segment from a relative wiki path
// "wiki/<wing>/<hall>/<slug>.md".
func wingOf(relPath string) (string, error) {
parts := strings.Split(relPath, "/")
if len(parts) < 4 || parts[0] != "wiki" {
return "", fmt.Errorf("not a wiki path: %q", relPath)
}
if parts[1] == "" {
return "", fmt.Errorf("empty wing in path: %q", relPath)
}
return parts[1], nil
}
// appendSeeAlso inserts `- [[link]]` under the file's See also section,
// creating the section if absent. No-op when the link is already present.
func appendSeeAlso(filePath, link string) error {
content, err := os.ReadFile(filePath)
if err != nil {
return err
}
wikilink := "[[" + link + "]]"
if strings.Contains(string(content), wikilink) {
return nil
}
bullet := "- " + wikilink
if !strings.Contains(string(content), seeAlsoHeader) {
// No section yet — append a fresh one. Always emit a trailing
// newline so subsequent appends don't merge into the previous line.
trimmed := strings.TrimRight(string(content), "\n")
out := trimmed + "\n\n" + seeAlsoHeader + "\n\n" + bullet + "\n"
return os.WriteFile(filePath, []byte(out), 0o644)
}
// Section exists — splice the bullet in just before the next `## `
// heading (or EOF). Reading the file line-by-line keeps this robust
// against arbitrary section ordering.
var b strings.Builder
scanner := bufio.NewScanner(strings.NewReader(string(content)))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
inSeeAlso, inserted := false, false
for scanner.Scan() {
line := scanner.Text()
if !inserted && inSeeAlso && strings.HasPrefix(line, "## ") &&
strings.TrimSpace(line) != seeAlsoHeader {
b.WriteString(bullet)
b.WriteByte('\n')
b.WriteByte('\n')
inserted = true
}
if strings.TrimSpace(line) == seeAlsoHeader {
inSeeAlso = true
}
b.WriteString(line)
b.WriteByte('\n')
}
if err := scanner.Err(); err != nil {
return err
}
if !inserted {
// section was the last thing in the file — just append bullet
out := strings.TrimRight(b.String(), "\n") + "\n" + bullet + "\n"
return os.WriteFile(filePath, []byte(out), 0o644)
}
return os.WriteFile(filePath, []byte(b.String()), 0o644)
}

View File

@@ -0,0 +1,177 @@
package brain_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// seedNote writes a minimal markdown note at brainDir/relPath with the given body.
func seedNote(t *testing.T, brainDir, relPath, body string) {
t.Helper()
full := filepath.Join(brainDir, relPath)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
}
func TestWriteTunnel_AppendsBidirectionalLinks(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/jepa-fx/decisions/val-vol.md",
"---\nwing: jepa-fx\nhall: decisions\n---\n# Val Vol R2\n\nbody.\n")
seedNote(t, dir, "wiki/hyperguild/decisions/routing.md",
"---\nwing: hyperguild\nhall: decisions\n---\n# Routing\n\nbody.\n")
err := brain.WriteTunnel(dir,
"wiki/jepa-fx/decisions/val-vol.md",
"wiki/hyperguild/decisions/routing.md",
)
require.NoError(t, err)
src, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/decisions/val-vol.md"))
require.NoError(t, err)
assert.Contains(t, string(src), "## See also")
assert.Contains(t, string(src), "[[hyperguild/decisions/routing]]")
tgt, err := os.ReadFile(filepath.Join(dir, "wiki/hyperguild/decisions/routing.md"))
require.NoError(t, err)
assert.Contains(t, string(tgt), "## See also")
assert.Contains(t, string(tgt), "[[jepa-fx/decisions/val-vol]]")
}
func TestWriteTunnel_Idempotent(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/a/facts/x.md", "# X\n\nbody.\n")
seedNote(t, dir, "wiki/b/facts/y.md", "# Y\n\nbody.\n")
for i := 0; i < 3; i++ {
require.NoError(t, brain.WriteTunnel(dir,
"wiki/a/facts/x.md", "wiki/b/facts/y.md"))
}
src, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/x.md"))
require.NoError(t, err)
assert.Equal(t, 1, strings.Count(string(src), "[[b/facts/y]]"),
"link should appear exactly once after 3 calls")
assert.Equal(t, 1, strings.Count(string(src), "## See also"))
tgt, err := os.ReadFile(filepath.Join(dir, "wiki/b/facts/y.md"))
require.NoError(t, err)
assert.Equal(t, 1, strings.Count(string(tgt), "[[a/facts/x]]"))
assert.Equal(t, 1, strings.Count(string(tgt), "## See also"))
}
func TestWriteTunnel_RejectsSameWing(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/jepa-fx/facts/x.md", "x")
seedNote(t, dir, "wiki/jepa-fx/facts/y.md", "y")
err := brain.WriteTunnel(dir,
"wiki/jepa-fx/facts/x.md", "wiki/jepa-fx/facts/y.md")
require.Error(t, err)
assert.Contains(t, err.Error(), "cross wings")
}
func TestWriteTunnel_RejectsMissingNote(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/a/facts/x.md", "x")
err := brain.WriteTunnel(dir,
"wiki/a/facts/x.md", "wiki/b/facts/ghost.md")
require.Error(t, err)
}
func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/jepa-fx/decisions/val-vol.md",
"---\nwing: jepa-fx\nhall: decisions\ntitle: Val Vol R2\n---\nbody.\n")
seedNote(t, dir, "wiki/jepa-fx/facts/lejpa.md",
"---\nwing: jepa-fx\nhall: facts\ntitle: LeJPA Architecture\n---\nbody.\n")
candidates, err := brain.DetectTunnels(dir,
"We need to revisit Val Vol R2 in light of new tier data.")
require.NoError(t, err)
require.Len(t, candidates, 1)
assert.Equal(t, "wiki/jepa-fx/decisions/val-vol.md", candidates[0].TargetPath)
assert.Equal(t, "Val Vol R2", candidates[0].MatchedTerm)
assert.True(t, candidates[0].Exact)
}
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/x/facts/routing.md",
"---\ntitle: Routing\n---\nbody.\n")
// Substring of title appears in content, but not as a whole word.
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
require.NoError(t, err)
require.Len(t, candidates, 1)
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
}
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
candidates, err := brain.DetectTunnels(dir,
"Documented Widget Flags after the deploy issue.")
require.NoError(t, err)
require.Len(t, candidates, 1)
assert.True(t, candidates[0].Exact)
assert.Equal(t, "widget flags", candidates[0].MatchedTerm)
}
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
dir := t.TempDir()
// Existing note in a different wing whose title is "Routing".
seedNote(t, dir, "wiki/other/facts/routing.md",
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
// Source note in another wing whose body mentions "rerouting" (substring match only).
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
"---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n")
require.NoError(t, brain.AutoTunnel(dir,
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
// Source must not get auto-linked (fuzzy).
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
require.NoError(t, err)
assert.NotContains(t, string(got), "[[other/facts/routing]]")
// Candidates file must list the pair.
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
require.NoError(t, err)
require.Len(t, matches, 1)
body, err := os.ReadFile(matches[0])
require.NoError(t, err)
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
assert.Contains(t, string(body), "wiki/other/facts/routing.md")
assert.Contains(t, string(body), "Routing")
}
func TestDetectTunnels_EmptyWiki(t *testing.T) {
dir := t.TempDir()
cs, err := brain.DetectTunnels(dir, "anything")
require.NoError(t, err)
assert.Empty(t, cs)
}
func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) {
dir := t.TempDir()
seedNote(t, dir, "wiki/a/facts/x.md",
"# X\n\nbody.\n\n## See also\n\n- [[a/facts/old]]\n")
seedNote(t, dir, "wiki/b/facts/y.md", "# Y\n\nbody.\n")
require.NoError(t, brain.WriteTunnel(dir,
"wiki/a/facts/x.md", "wiki/b/facts/y.md"))
src, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/x.md"))
require.NoError(t, err)
s := string(src)
assert.Equal(t, 1, strings.Count(s, "## See also"), "should reuse existing section")
assert.Contains(t, s, "[[a/facts/old]]")
assert.Contains(t, s, "[[b/facts/y]]")
}

View File

@@ -0,0 +1,110 @@
package claudewatcher
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// CursorStore tracks how far the watcher has ingested into each
// session JSONL file. Keyed by (host, file_path) so the same `~/.claude`
// path on different hosts doesn't collide and resumability survives
// pod restarts. Idempotent Init lives alongside the rest of the
// claudewatcher schema; no separate migration framework.
type CursorStore struct {
pool *pgxpool.Pool
}
// NewCursorStore opens a pool against dsn. Caller closes the store.
func NewCursorStore(ctx context.Context, dsn string) (*CursorStore, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("pgxpool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return &CursorStore{pool: pool}, nil
}
// NewCursorStoreFromPool wraps an existing pool (so the watcher can
// share the brain DSN pool with vectorstore/graphstore without a
// second connection set). Caller must NOT close the wrapped pool via
// the store — close the pool directly.
func NewCursorStoreFromPool(pool *pgxpool.Pool) *CursorStore {
return &CursorStore{pool: pool}
}
// Close releases the underlying connection pool when this store owns
// it. No-op when the pool was injected via NewCursorStoreFromPool —
// pgxpool.Close is idempotent so we lean on that.
func (s *CursorStore) Close() {
if s.pool != nil {
s.pool.Close()
}
}
// Init creates the claude_session_cursors table when missing.
func (s *CursorStore) Init(ctx context.Context) error {
const ddl = `
CREATE TABLE IF NOT EXISTS claude_session_cursors (
host TEXT NOT NULL,
file_path TEXT NOT NULL,
byte_offset BIGINT NOT NULL DEFAULT 0,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (host, file_path)
);
CREATE INDEX IF NOT EXISTS claude_session_cursors_host_idx
ON claude_session_cursors (host);
`
_, err := s.pool.Exec(ctx, ddl)
return err
}
// GetOffset returns the last recorded byte offset for (host, filePath).
// Missing rows are reported as offset=0, ok=false so the caller can
// distinguish "never ingested" from "ingested at the start of the
// file" (both produce identical behaviour but the metric is useful).
func (s *CursorStore) GetOffset(ctx context.Context, host, filePath string) (int64, bool, error) {
if host == "" || filePath == "" {
return 0, false, errors.New("host and file_path are required")
}
var offset int64
err := s.pool.QueryRow(ctx, `
SELECT byte_offset FROM claude_session_cursors WHERE host = $1 AND file_path = $2
`, host, filePath).Scan(&offset)
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("query: %w", err)
}
return offset, true, nil
}
// SetOffset writes the new offset for (host, filePath). Used after
// every successful parse + ingest batch so a crash mid-file rewinds
// only to the last committed checkpoint.
func (s *CursorStore) SetOffset(ctx context.Context, host, filePath string, offset int64) error {
if host == "" || filePath == "" {
return errors.New("host and file_path are required")
}
if offset < 0 {
return errors.New("offset must be >= 0")
}
_, err := s.pool.Exec(ctx, `
INSERT INTO claude_session_cursors (host, file_path, byte_offset, last_seen_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (host, file_path) DO UPDATE
SET byte_offset = EXCLUDED.byte_offset,
last_seen_at = now()
`, host, filePath, offset)
if err != nil {
return fmt.Errorf("upsert offset: %w", err)
}
return nil
}

View File

@@ -0,0 +1,305 @@
// Package claudewatcher ingests Claude Code session transcripts
// (`~/.claude/projects/*/<uuid>.jsonl`) into the brain corpus.
//
// Schema (observed 2026-05-25 across ~30 session files on koala):
//
// type=user — user prompts + tool results
// type=assistant — model turns; tool_use blocks live in message.content
// type=attachment — hook outputs, ingested files
// type=system — turn-boundary metadata
// type=file-history-snapshot — git-style snapshot of edited files
// type=queue-operation, last-prompt, permission-mode, ai-title,
// bridge-session — internal bookkeeping, ignored
//
// The parser is intentionally tolerant: malformed lines are skipped
// (caller logs and advances), missing optional fields default to "",
// and unknown `type` values are returned as Turn entries with
// `Skip=true` so callers can filter cheaply.
package claudewatcher
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
)
// Turn is one parsed JSONL entry from a Claude Code session log.
//
// Skip is true for entry types we never want to ingest (queue
// bookkeeping, snapshots, etc.). Callers fast-path these without
// running the scrubber or classifier.
type Turn struct {
SessionID string
Type string
ParentUUID string
Timestamp time.Time
Cwd string
GitBranch string
Content string // plain-text projection of the entry, ready for the scrubber/classifier
ToolName string // populated when an assistant turn invokes a tool
OffsetAfter int64 // byte offset in the file just past this entry
Skip bool
ParseWarning string // non-empty when the entry parsed but had a sub-field we couldn't normalise
}
// ParseStream reads JSONL lines from r starting at startOffset and
// invokes emit for each parsed entry. emit may return ErrStop to
// terminate the scan cleanly. Other emit errors propagate.
//
// startOffset is informational — the caller is expected to have already
// seeked the underlying reader to that offset. ParseStream adds the
// number of bytes consumed per line to it to compute Turn.OffsetAfter.
//
// Lines that fail to unmarshal are logged via warnf and skipped; they
// do NOT advance OffsetAfter past the malformed line by themselves,
// but the next valid line resumes correctly because bufio.Scanner
// preserves stream position.
func ParseStream(
r io.Reader,
startOffset int64,
warnf func(format string, args ...any),
emit func(Turn) error,
) (int64, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // some lines are big (tool outputs)
offset := startOffset
for scanner.Scan() {
raw := scanner.Bytes()
lineLen := int64(len(raw)) + 1 // +1 for the newline
t, err := parseTurn(raw)
if err != nil {
if warnf != nil {
warnf("parse: %v (%d bytes)", err, len(raw))
}
offset += lineLen
continue
}
t.OffsetAfter = offset + lineLen
if err := emit(t); err != nil {
if errors.Is(err, ErrStop) {
return t.OffsetAfter, nil
}
return offset, fmt.Errorf("emit: %w", err)
}
offset = t.OffsetAfter
}
if err := scanner.Err(); err != nil {
return offset, fmt.Errorf("scan: %w", err)
}
return offset, nil
}
// ErrStop terminates a ParseStream loop without surfacing an error.
var ErrStop = errors.New("claudewatcher: stop")
// rawEntry is a permissive shape that covers every type observed in
// the JSONL files. Fields we don't care about are intentionally
// omitted to keep the unmarshal cheap.
type rawEntry struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
ParentUUID string `json:"parentUuid"`
Timestamp string `json:"timestamp"`
Cwd string `json:"cwd"`
GitBranch string `json:"gitBranch"`
Message json.RawMessage `json:"message"`
Attachment json.RawMessage `json:"attachment"`
Content string `json:"content"` // queue-operation
LastPrompt string `json:"lastPrompt"` // last-prompt
Subtype string `json:"subtype"` // system
}
// skipTypes lists every entry type we want to never ingest. Marked Skip
// at parse time so the caller's filter is a single boolean check.
var skipTypes = map[string]struct{}{
"queue-operation": {},
"last-prompt": {},
"permission-mode": {},
"ai-title": {},
"bridge-session": {},
"file-history-snapshot": {},
}
func parseTurn(raw []byte) (Turn, error) {
var e rawEntry
if err := json.Unmarshal(raw, &e); err != nil {
return Turn{}, fmt.Errorf("unmarshal: %w", err)
}
t := Turn{
Type: e.Type,
SessionID: e.SessionID,
ParentUUID: e.ParentUUID,
Cwd: e.Cwd,
GitBranch: e.GitBranch,
}
if _, skip := skipTypes[e.Type]; skip {
t.Skip = true
return t, nil
}
if e.Timestamp != "" {
if ts, err := time.Parse(time.RFC3339Nano, e.Timestamp); err == nil {
t.Timestamp = ts
} else {
t.ParseWarning = "timestamp"
}
}
switch e.Type {
case "user":
t.Content = extractMessageText(e.Message)
case "assistant":
t.Content, t.ToolName = extractAssistantTurn(e.Message)
case "attachment":
t.Content = extractAttachmentText(e.Attachment)
case "system":
t.Content = "[system " + e.Subtype + "]"
default:
// Unknown type — keep the row but mark Skip so callers ignore.
t.Skip = true
}
return t, nil
}
// extractMessageText pulls the textual projection out of a user/assistant
// message field. The shape is the Anthropic Messages API content-block
// array (an array of {type, text|tool_use|tool_result, ...}). We
// concatenate every text-bearing block and ignore the rest.
func extractMessageText(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var msg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
Stop string `json:"stop_reason"`
Model string `json:"model"`
Usage map[string]any `json:"usage"`
Meta map[string]string `json:"meta"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
// Some user turns have message as plain string.
var s string
if err2 := json.Unmarshal(raw, &s); err2 == nil {
return s
}
return ""
}
// Content can be a string OR an array.
var asString string
if err := json.Unmarshal(msg.Content, &asString); err == nil {
return asString
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
Content json.RawMessage `json:"content"`
}
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
return ""
}
var sb strings.Builder
for _, b := range blocks {
switch b.Type {
case "text":
sb.WriteString(b.Text)
sb.WriteByte('\n')
case "tool_result":
// Tool result content may itself be a string or array of blocks.
var s string
if err := json.Unmarshal(b.Content, &s); err == nil {
sb.WriteString("[tool_result] ")
sb.WriteString(s)
sb.WriteByte('\n')
continue
}
var sub []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(b.Content, &sub); err == nil {
for _, s := range sub {
if s.Type == "text" {
sb.WriteString("[tool_result] ")
sb.WriteString(s.Text)
sb.WriteByte('\n')
}
}
}
}
}
return strings.TrimRight(sb.String(), "\n")
}
// extractAssistantTurn pulls text + the first tool name (if any) from
// an assistant content-block array. Multi-tool turns lose the second
// name; the goal is signal for classification, not perfect fidelity.
func extractAssistantTurn(raw json.RawMessage) (string, string) {
if len(raw) == 0 {
return "", ""
}
var msg struct {
Content json.RawMessage `json:"content"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
return "", ""
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
Name string `json:"name"`
Tool json.RawMessage `json:"input"`
}
if err := json.Unmarshal(msg.Content, &blocks); err != nil {
return "", ""
}
var sb strings.Builder
var firstTool string
for _, b := range blocks {
switch b.Type {
case "text":
sb.WriteString(b.Text)
sb.WriteByte('\n')
case "tool_use":
if firstTool == "" {
firstTool = b.Name
}
sb.WriteString("[tool_use:")
sb.WriteString(b.Name)
sb.WriteString("]\n")
}
}
return strings.TrimRight(sb.String(), "\n"), firstTool
}
// extractAttachmentText pulls text content from an attachment payload,
// or returns a short tag when the attachment is a hook event.
func extractAttachmentText(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var a struct {
Type string `json:"type"`
HookName string `json:"hookName"`
HookEvent string `json:"hookEvent"`
Content string `json:"content"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return ""
}
if a.Content != "" {
return a.Content
}
if a.Text != "" {
return a.Text
}
if a.HookName != "" {
return "[hook " + a.HookEvent + ":" + a.HookName + "]"
}
return ""
}

View File

@@ -0,0 +1,157 @@
package claudewatcher
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func collect(t *testing.T, body string) ([]Turn, int64, error) {
t.Helper()
var out []Turn
end, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
out = append(out, tr)
return nil
})
return out, end, err
}
func TestParseStream_UserTurnStringContent(t *testing.T) {
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":"hello world"}
`
turns, end, err := collect(t, body)
require.NoError(t, err)
require.Len(t, turns, 1)
assert.Equal(t, "user", turns[0].Type)
assert.Equal(t, "S", turns[0].SessionID)
assert.Equal(t, "hello world", turns[0].Content)
assert.False(t, turns[0].Skip)
assert.Equal(t, int64(len(body)), end)
}
func TestParseStream_UserTurnContentBlocks(t *testing.T) {
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"role":"user","content":[{"type":"text","text":"line 1"},{"type":"text","text":"line 2"}]}}
`
turns, _, err := collect(t, body)
require.NoError(t, err)
require.Len(t, turns, 1)
assert.Equal(t, "line 1\nline 2", turns[0].Content)
}
func TestParseStream_AssistantToolUse(t *testing.T) {
body := `{"type":"assistant","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"content":[{"type":"text","text":"calling now"},{"type":"tool_use","name":"Edit","input":{}}]}}
`
turns, _, err := collect(t, body)
require.NoError(t, err)
require.Len(t, turns, 1)
assert.Equal(t, "Edit", turns[0].ToolName)
assert.Contains(t, turns[0].Content, "calling now")
assert.Contains(t, turns[0].Content, "[tool_use:Edit]")
}
func TestParseStream_AssistantToolResult(t *testing.T) {
body := `{"type":"user","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","message":{"content":[{"type":"tool_result","content":"output of cmd"}]}}
`
turns, _, err := collect(t, body)
require.NoError(t, err)
require.Len(t, turns, 1)
assert.Contains(t, turns[0].Content, "[tool_result] output of cmd")
}
func TestParseStream_SkipsBookkeepingTypes(t *testing.T) {
body := strings.Join([]string{
`{"type":"queue-operation","sessionId":"S","content":"x"}`,
`{"type":"last-prompt","sessionId":"S","lastPrompt":"y"}`,
`{"type":"permission-mode","sessionId":"S","permissionMode":"auto"}`,
`{"type":"ai-title","sessionId":"S","aiTitle":"My session"}`,
`{"type":"file-history-snapshot","messageId":"abc"}`,
}, "\n") + "\n"
turns, _, err := collect(t, body)
require.NoError(t, err)
require.Len(t, turns, 5)
for _, tr := range turns {
assert.True(t, tr.Skip, "expected Skip=true for %q", tr.Type)
}
}
func TestParseStream_UnknownTypeIsSkip(t *testing.T) {
body := `{"type":"future-thing","sessionId":"S"}` + "\n"
turns, _, err := collect(t, body)
require.NoError(t, err)
require.Len(t, turns, 1)
assert.True(t, turns[0].Skip)
}
func TestParseStream_MalformedLineIsSkippedNotFatal(t *testing.T) {
body := strings.Join([]string{
`{"type":"user","sessionId":"S","message":"first"}`,
`{not valid json`,
`{"type":"user","sessionId":"S","message":"third"}`,
}, "\n") + "\n"
var warnings int
var turns []Turn
_, err := ParseStream(strings.NewReader(body), 0, func(format string, args ...any) {
warnings++
}, func(tr Turn) error {
turns = append(turns, tr)
return nil
})
require.NoError(t, err)
require.Len(t, turns, 2, "first + third should make it through")
assert.Equal(t, 1, warnings)
}
func TestParseStream_EmitErrStopHaltsCleanly(t *testing.T) {
body := strings.Join([]string{
`{"type":"user","sessionId":"S","message":"a"}`,
`{"type":"user","sessionId":"S","message":"b"}`,
`{"type":"user","sessionId":"S","message":"c"}`,
}, "\n") + "\n"
count := 0
end, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
count++
if count == 2 {
return ErrStop
}
return nil
})
require.NoError(t, err)
assert.Equal(t, 2, count)
assert.Greater(t, end, int64(0))
}
func TestParseStream_EmitOtherErrorPropagates(t *testing.T) {
body := `{"type":"user","sessionId":"S","message":"a"}` + "\n"
want := errors.New("boom")
_, err := ParseStream(strings.NewReader(body), 0, nil, func(tr Turn) error {
return want
})
require.Error(t, err)
assert.Contains(t, err.Error(), "boom")
}
func TestParseStream_AttachmentHookEvent(t *testing.T) {
body := `{"type":"attachment","sessionId":"S","timestamp":"2026-05-25T07:00:00Z","attachment":{"type":"hook_success","hookName":"SessionStart:startup","hookEvent":"SessionStart","content":"hook body"}}
`
turns, _, err := collect(t, body)
require.NoError(t, err)
require.Len(t, turns, 1)
assert.Equal(t, "hook body", turns[0].Content)
}
func TestParseStream_OffsetAdvances(t *testing.T) {
body := `{"type":"user","sessionId":"S","message":"a"}` + "\n" +
`{"type":"user","sessionId":"S","message":"b"}` + "\n"
var offsets []int64
_, err := ParseStream(strings.NewReader(body), 100, nil, func(tr Turn) error {
offsets = append(offsets, tr.OffsetAfter)
return nil
})
require.NoError(t, err)
require.Len(t, offsets, 2)
assert.Greater(t, offsets[0], int64(100))
assert.Greater(t, offsets[1], offsets[0])
}

View File

@@ -0,0 +1,62 @@
package claudewatcher
import "regexp"
// Scrubber drops any turn whose content matches a known-bad pattern.
// Fail-closed by design: we'd rather lose signal than ingest credentials
// into a public-readable brain. The caller logs the drop reason.
//
// Rules cover the credential shapes most common to leak through Claude
// Code sessions: bearer tokens, postgres URIs with embedded auth, OAuth
// secret values, SOPS-encrypted secret blobs (we don't want the
// ciphertext either — it's a marker that the original message contained
// secret state), PEM-encoded private keys, and the explicit env-var
// naming conventions used in the homelab.
//
// Pattern philosophy: match by shape, not by content. A 40-char hex
// string in isolation is fine; the same string after `Authorization:
// Bearer ` is not. Tuned to catch known leak vectors from prior
// secret-hygiene incidents (POSTGRES_PASSWORD via kubectl exec env,
// INFRA_MCP_TOKEN via sops -d output) without dropping every Edit on a
// config file.
// Rule is a single named regex with a redact hint shown in the warn log.
type Rule struct {
Name string
RE *regexp.Regexp
}
// DefaultRules is the regex set applied by Scrub. Mutable for tests but
// callers should treat it as read-only at runtime.
var DefaultRules = []Rule{
// authorization-header is checked before the bare bearer rule so
// contextual hits ("Authorization: Bearer X") report the more
// specific match name in logs.
{Name: "authorization-header", RE: regexp.MustCompile(`(?i)Authorization\s*:\s*[A-Za-z]+\s+\S{8,}`)},
{Name: "bearer-token", RE: regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-]{16,}`)},
{Name: "postgres-uri-with-password", RE: regexp.MustCompile(`postgres(?:ql)?://[^:\s/]+:[^@\s/]+@`)},
{Name: "private-key", RE: regexp.MustCompile(`-----BEGIN[^-]*PRIVATE KEY-----`)},
{Name: "ssh-key", RE: regexp.MustCompile(`ssh-(?:rsa|ed25519|ecdsa)\s+[A-Za-z0-9+/=]{40,}`)},
{Name: "github-pat", RE: regexp.MustCompile(`\b(?:ghp|gho|ghu|ghr|gha)_[A-Za-z0-9]{30,}\b`)},
{Name: "openai-sk", RE: regexp.MustCompile(`\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b`)},
{Name: "anthropic-sk", RE: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_\-]{32,}\b`)},
{Name: "aws-access-key", RE: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`)},
{Name: "homelab-env-token", RE: regexp.MustCompile(`(?i)(?:_TOKEN|_PASSWORD|_API_KEY|_SECRET)\s*[:=]\s*['"]?[A-Za-z0-9._/+\-]{12,}`)},
{Name: "sops-encrypted-marker", RE: regexp.MustCompile(`ENC\[AES256_GCM,data:[A-Za-z0-9+/=]{8,}`)},
}
// Scrub reports the first matching rule, or empty when content is clean.
// Empty string is treated as clean. Caller decides what to do on a hit;
// the convention in claudewatcher is to drop the turn entirely and emit
// a slog.Warn naming the rule.
func Scrub(content string) string {
if content == "" {
return ""
}
for _, r := range DefaultRules {
if r.RE.MatchString(content) {
return r.Name
}
}
return ""
}

View File

@@ -0,0 +1,57 @@
package claudewatcher
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestScrub_PoisonedFixtures(t *testing.T) {
// One representative bad-string per rule. If a rule fires for the
// wrong content shape later, this table localises the regression.
cases := []struct {
name string
content string
want string
}{
{"bearer-token", "curl -H 'Authorization: Bearer abcdef1234567890ghijklmnop'", "authorization-header"},
{"bearer-no-header", "header = Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig", "bearer-token"},
{"postgres-uri", "DATABASE_URL=postgres://user:s3cret@10.0.1.20:5432/brain", "postgres-uri-with-password"},
{"private-key", "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA", "private-key"},
{"ssh-public", "deploy: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK1234567890abcdefghij user@host", "ssh-key"},
{"github-pat-classic", "GH_TOKEN=ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt", "github-pat"},
{"openai-key", "OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII", "openai-sk"},
{"anthropic-key", "ANTHROPIC_API_KEY=sk-ant-api03-aaaaBBBBccccDDDDeeeeFFFFggggHHHHiiiiJJJJkkkk", "anthropic-sk"},
{"aws-access-key", "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", "aws-access-key"},
{"homelab-env", "POSTGRES_PASSWORD=hunter2supersecretvalue", "homelab-env-token"},
{"sops-marker", "value: ENC[AES256_GCM,data:abc123def456,iv:zzz]", "sops-encrypted-marker"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Scrub(tc.content)
assert.Equal(t, tc.want, got)
})
}
}
func TestScrub_CleanContentPassesThrough(t *testing.T) {
cases := []string{
"",
"plain text with no credentials",
"a 40 char hex string aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa is fine in isolation",
"`Bearer` token mentioned in docs without an actual value",
"file at ~/.ssh/id_ed25519",
"the function Authorization() takes no args",
"comment: see API key in 1Password",
}
for _, c := range cases {
assert.Empty(t, Scrub(c), "expected clean for %q", c)
}
}
func TestScrub_FirstMatchWins(t *testing.T) {
// Content matching multiple rules: report the first rule order in
// DefaultRules. Stability matters for log triage.
content := "Authorization: Bearer ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt"
assert.Equal(t, "authorization-header", Scrub(content))
}

View File

@@ -0,0 +1,234 @@
package claudewatcher
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
)
// Sink consumes batches of ingest-ready turns from the watcher. The
// production implementation builds wiki pages and calls pipeline.RunRaw
// against the brain. Tests substitute a counter.
//
// A Batch represents the turns ingested from one session file between
// two cursor checkpoints. Implementations must be idempotent — the
// watcher only advances the cursor on a nil return.
type Sink interface {
Ingest(ctx context.Context, b Batch) error
}
// Batch is a per-file slice of turns plus identifying metadata.
type Batch struct {
Host string // origin host, e.g. "koala"
FilePath string // absolute path to the source .jsonl file
SessionID string // first session_id seen in the batch
ProjectID string // basename of the parent dir, e.g. "-home-mathias-dev"
Turns []Turn // never empty; caller filters Skip + scrubber matches
}
// Config drives one Watch loop. SessionsDir is the absolute path to the
// Claude Code projects directory (~/.claude/projects). Host is the
// label written into cursors and ingested page frontmatter. Interval
// is the poll cadence; a zero or negative value disables the loop.
//
// Sink is required. Cursors is optional — when nil the watcher
// re-reads from byte 0 on every tick (useful for first-run testing
// without a postgres dependency).
type Config struct {
SessionsDir string
Host string
Interval time.Duration
Sink Sink
Cursors *CursorStore
Logger *slog.Logger
}
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
// on shutdown. Each tick walks SessionsDir for *.jsonl files, advances
// each file's cursor, and emits one Batch per file with new turns.
// Errors during a single file's parse or ingest are logged but do not
// abort the loop — a single bad file shouldn't block the others.
func Watch(ctx context.Context, cfg Config) error {
if cfg.SessionsDir == "" {
return fmt.Errorf("sessions dir is required")
}
if cfg.Sink == nil {
return fmt.Errorf("sink is required")
}
if cfg.Interval <= 0 {
return fmt.Errorf("interval must be positive")
}
if cfg.Host == "" {
cfg.Host = "unknown"
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
cfg.Logger.Info("claudewatcher: started",
"sessions_dir", cfg.SessionsDir,
"host", cfg.Host,
"interval", cfg.Interval)
ticker := time.NewTicker(cfg.Interval)
defer ticker.Stop()
// Run an immediate first sweep so first-launch users don't wait one
// tick before anything happens.
runTick(ctx, cfg)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
runTick(ctx, cfg)
}
}
}
// runTick is one polling pass. Exposed (lowercase) for tests via
// TickOnce.
func runTick(ctx context.Context, cfg Config) {
files, err := listSessionFiles(cfg.SessionsDir)
if err != nil {
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
return
}
for _, f := range files {
if ctx.Err() != nil {
return
}
if err := processFile(ctx, cfg, f); err != nil {
cfg.Logger.Warn("claudewatcher: file failed",
"path", f, "err", err)
}
}
}
// TickOnce runs one sweep synchronously and returns. Used by tests +
// by ad-hoc CLI invocations.
func TickOnce(ctx context.Context, cfg Config) error {
if cfg.SessionsDir == "" || cfg.Sink == nil {
return fmt.Errorf("config invalid")
}
if cfg.Host == "" {
cfg.Host = "unknown"
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
runTick(ctx, cfg)
return nil
}
func listSessionFiles(root string) ([]string, error) {
var out []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".jsonl") {
return nil
}
out = append(out, path)
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %s: %w", root, err)
}
return out, nil
}
func processFile(ctx context.Context, cfg Config, path string) error {
startOffset := int64(0)
if cfg.Cursors != nil {
off, _, err := cfg.Cursors.GetOffset(ctx, cfg.Host, path)
if err != nil {
return fmt.Errorf("get cursor: %w", err)
}
startOffset = off
}
stat, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat: %w", err)
}
if stat.Size() <= startOffset {
return nil // nothing new
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer func() { _ = f.Close() }()
if _, err := f.Seek(startOffset, 0); err != nil {
return fmt.Errorf("seek: %w", err)
}
var keep []Turn
var sessionID string
var droppedScrub int
endOffset, err := ParseStream(f, startOffset,
func(format string, args ...any) {
cfg.Logger.Warn(fmt.Sprintf("claudewatcher: parse: "+format, args...))
},
func(t Turn) error {
if t.Skip || t.Content == "" {
return nil
}
if rule := Scrub(t.Content); rule != "" {
droppedScrub++
cfg.Logger.Warn("claudewatcher: turn dropped by scrubber",
"rule", rule, "path", path, "session_id", t.SessionID)
return nil
}
if sessionID == "" {
sessionID = t.SessionID
}
keep = append(keep, t)
return nil
})
if err != nil {
return fmt.Errorf("parse stream: %w", err)
}
if len(keep) == 0 {
if cfg.Cursors != nil {
if err := cfg.Cursors.SetOffset(ctx, cfg.Host, path, endOffset); err != nil {
return fmt.Errorf("advance cursor (no-turns): %w", err)
}
}
if droppedScrub > 0 {
cfg.Logger.Info("claudewatcher: only scrubbed turns this tick",
"path", path, "dropped", droppedScrub)
}
return nil
}
batch := Batch{
Host: cfg.Host,
FilePath: path,
SessionID: sessionID,
ProjectID: filepath.Base(filepath.Dir(path)),
Turns: keep,
}
if err := cfg.Sink.Ingest(ctx, batch); err != nil {
return fmt.Errorf("sink ingest: %w", err)
}
if cfg.Cursors != nil {
if err := cfg.Cursors.SetOffset(ctx, cfg.Host, path, endOffset); err != nil {
return fmt.Errorf("advance cursor: %w", err)
}
}
cfg.Logger.Info("claudewatcher: ingested batch",
"path", path, "session_id", sessionID,
"turns_kept", len(keep), "dropped_scrub", droppedScrub,
"new_offset", endOffset)
return nil
}

View File

@@ -0,0 +1,174 @@
package claudewatcher
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// memSink captures batches without touching postgres. Thread-safe so
// TickOnce can run from any goroutine in concurrent tests.
type memSink struct {
mu sync.Mutex
batches []Batch
failOn string // file basename to error on
}
func (m *memSink) Ingest(_ context.Context, b Batch) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.failOn != "" && strings.Contains(b.FilePath, m.failOn) {
return assert.AnError
}
m.batches = append(m.batches, b)
return nil
}
func writeSession(t *testing.T, dir, sessionID string, lines []string) string {
t.Helper()
path := filepath.Join(dir, sessionID+".jsonl")
body := strings.Join(lines, "\n") + "\n"
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
return path
}
func TestTickOnce_NoCursorReingestsEverythingEveryTick(t *testing.T) {
tmp := t.TempDir()
projectDir := filepath.Join(tmp, "-home-mathias-dev")
require.NoError(t, os.MkdirAll(projectDir, 0o755))
writeSession(t, projectDir, "sess1", []string{
`{"type":"user","sessionId":"sess1","message":"first prompt"}`,
`{"type":"assistant","sessionId":"sess1","message":{"content":[{"type":"text","text":"first answer"}]}}`,
})
sink := &memSink{}
cfg := Config{
SessionsDir: tmp,
Host: "koala",
Sink: sink,
}
require.NoError(t, TickOnce(context.Background(), cfg))
require.NoError(t, TickOnce(context.Background(), cfg))
require.Len(t, sink.batches, 2, "no cursor => re-emits same batch every tick")
assert.Equal(t, "sess1", sink.batches[0].SessionID)
assert.Equal(t, "koala", sink.batches[0].Host)
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
assert.Len(t, sink.batches[0].Turns, 2)
}
func TestTickOnce_FiltersSkipTurnsAndScrubberMatches(t *testing.T) {
tmp := t.TempDir()
proj := filepath.Join(tmp, "-home-mathias-dev")
require.NoError(t, os.MkdirAll(proj, 0o755))
writeSession(t, proj, "sess-scrub", []string{
`{"type":"queue-operation","sessionId":"sess-scrub","content":"x"}`, // Skip
`{"type":"user","sessionId":"sess-scrub","message":"normal prompt"}`,
`{"type":"assistant","sessionId":"sess-scrub","message":{"content":[{"type":"text","text":"value POSTGRES_PASSWORD=hunter2supersecretvalue"}]}}`, // scrubbed
})
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp, Host: "koala", Sink: sink,
}))
require.Len(t, sink.batches, 1)
turns := sink.batches[0].Turns
require.Len(t, turns, 1, "skip + scrubbed turns must not reach the sink")
assert.Equal(t, "user", turns[0].Type)
}
func TestTickOnce_AllScrubbedNoBatchEmitted(t *testing.T) {
tmp := t.TempDir()
proj := filepath.Join(tmp, "-home-mathias-dev")
require.NoError(t, os.MkdirAll(proj, 0o755))
writeSession(t, proj, "all-bad", []string{
`{"type":"user","sessionId":"all-bad","message":"Authorization: Bearer abcdef1234567890ghijklmnop"}`,
})
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp, Host: "koala", Sink: sink,
}))
assert.Empty(t, sink.batches, "no usable turns => no batch")
}
func TestTickOnce_IgnoresNonJsonlFiles(t *testing.T) {
tmp := t.TempDir()
proj := filepath.Join(tmp, "-home-mathias-dev")
require.NoError(t, os.MkdirAll(proj, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(proj, "README.md"), []byte("ignore me"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(proj, "config.json"), []byte("{}"), 0o644))
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp, Host: "koala", Sink: sink,
}))
assert.Empty(t, sink.batches)
}
func TestTickOnce_HandlesMultipleProjectsAndSessions(t *testing.T) {
tmp := t.TempDir()
projA := filepath.Join(tmp, "-home-mathias-dev")
projB := filepath.Join(tmp, "-home-mathias-AI-infra")
require.NoError(t, os.MkdirAll(projA, 0o755))
require.NoError(t, os.MkdirAll(projB, 0o755))
writeSession(t, projA, "a1", []string{`{"type":"user","sessionId":"a1","message":"q1"}`})
writeSession(t, projA, "a2", []string{`{"type":"user","sessionId":"a2","message":"q2"}`})
writeSession(t, projB, "b1", []string{`{"type":"user","sessionId":"b1","message":"q3"}`})
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp, Host: "koala", Sink: sink,
}))
require.Len(t, sink.batches, 3)
projects := map[string]int{}
for _, b := range sink.batches {
projects[b.ProjectID]++
}
assert.Equal(t, 2, projects["-home-mathias-dev"])
assert.Equal(t, 1, projects["-home-mathias-AI-infra"])
}
func TestTickOnce_SinkErrorDoesNotKillOtherFiles(t *testing.T) {
tmp := t.TempDir()
proj := filepath.Join(tmp, "-home-mathias-dev")
require.NoError(t, os.MkdirAll(proj, 0o755))
writeSession(t, proj, "good", []string{`{"type":"user","sessionId":"good","message":"q"}`})
writeSession(t, proj, "bad-session", []string{`{"type":"user","sessionId":"bad-session","message":"q"}`})
sink := &memSink{failOn: "bad-session"}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp, Host: "koala", Sink: sink,
}))
require.Len(t, sink.batches, 1, "good session still ingested")
assert.Equal(t, "good", sink.batches[0].SessionID)
}
func TestWatch_RespectsContextCancel(t *testing.T) {
tmp := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(tmp, "-home-mathias-dev"), 0o755))
sink := &memSink{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Watch(ctx, Config{
SessionsDir: tmp,
Host: "koala",
Interval: 10 * time.Millisecond,
Sink: sink,
})
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(2 * time.Second):
t.Fatal("Watch did not return after cancel")
}
}

View File

@@ -0,0 +1,76 @@
// Package embed produces dense vector embeddings for brain content.
//
// Wire format is Ollama's `/api/embed`, with the canonical request shape
// `{"model": "...", "input": "..."}` and a 2-D `embeddings` response.
// Default deployment runs `nomic-embed-text` on iguana, which returns
// 768-dim vectors compatible with the brain_embeddings table schema.
package embed
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client posts embedding requests to an Ollama-compatible endpoint.
type Client struct {
URL string
Model string
HTTP *http.Client
}
// New constructs a Client. Returns nil when url is empty so callers can
// treat a missing BRAIN_EMBED_URL as "feature disabled" via a single nil
// check.
func New(url, model string) *Client {
if url == "" {
return nil
}
return &Client{
URL: strings.TrimRight(url, "/"),
Model: model,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// Embed returns the embedding vector for text. Empty text is rejected
// up-front to keep upstream errors from masking caller mistakes.
func (c *Client) Embed(ctx context.Context, text string) ([]float32, error) {
if strings.TrimSpace(text) == "" {
return nil, fmt.Errorf("embed: empty text")
}
reqBody, _ := json.Marshal(map[string]any{
"model": c.Model,
"input": text,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.URL+"/api/embed", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("embed: status %d: %s", resp.StatusCode, string(body))
}
var out struct {
Embeddings [][]float32 `json:"embeddings"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("embed: decode: %w", err)
}
if len(out.Embeddings) == 0 || len(out.Embeddings[0]) == 0 {
return nil, fmt.Errorf("embed: empty embeddings in response")
}
return out.Embeddings[0], nil
}

View File

@@ -0,0 +1,74 @@
package embed_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/embed"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew_EmptyURLReturnsNil(t *testing.T) {
assert.Nil(t, embed.New("", "model"))
}
func TestEmbed_ReturnsVector(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/embed", r.URL.Path)
var req map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "nomic", req["model"])
assert.Equal(t, "hello", req["input"])
_ = json.NewEncoder(w).Encode(map[string]any{
"embeddings": [][]float32{{0.1, 0.2, 0.3}},
})
}))
defer srv.Close()
c := embed.New(srv.URL, "nomic")
require.NotNil(t, c)
v, err := c.Embed(context.Background(), "hello")
require.NoError(t, err)
assert.Equal(t, []float32{0.1, 0.2, 0.3}, v)
}
func TestEmbed_StripsTrailingSlashFromURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/embed", r.URL.Path)
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1.0}}})
}))
defer srv.Close()
c := embed.New(srv.URL+"/", "nomic")
_, err := c.Embed(context.Background(), "x")
require.NoError(t, err)
}
func TestEmbed_PropagatesUpstreamError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
c := embed.New(srv.URL, "m")
_, err := c.Embed(context.Background(), "x")
require.Error(t, err)
}
func TestEmbed_RejectsEmptyEmbeddingsArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{}})
}))
defer srv.Close()
c := embed.New(srv.URL, "m")
_, err := c.Embed(context.Background(), "x")
require.Error(t, err)
}
func TestEmbed_RejectsEmptyText(t *testing.T) {
c := embed.New("http://127.0.0.1:1", "m")
_, err := c.Embed(context.Background(), "")
require.Error(t, err)
}

View File

@@ -0,0 +1,39 @@
// ingestion/internal/extract/extract.go
package extract
import (
"fmt"
"os"
"strings"
)
// Text reads the file at path and returns its plain-text content.
// Supported extensions: .md, .txt (passthrough), .pdf (via pdftotext).
func Text(path string) (string, error) {
ext := strings.ToLower(fileExt(path))
switch ext {
case ".md", ".txt":
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
return string(b), nil
case ".pdf":
return extractPDF(path)
default:
return "", fmt.Errorf("unsupported file extension: %s", ext)
}
}
// fileExt returns the file extension including the dot, lowercased.
func fileExt(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '.' {
return path[i:]
}
if path[i] == '/' || path[i] == '\\' {
break
}
}
return ""
}

View File

@@ -0,0 +1,62 @@
// ingestion/internal/extract/extract_test.go
package extract
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestText_Markdown(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
require.NoError(t, os.WriteFile(path, []byte("# Hello\n\nWorld."), 0o644))
got, err := Text(path)
require.NoError(t, err)
assert.Equal(t, "# Hello\n\nWorld.", got)
}
func TestText_Txt(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
require.NoError(t, os.WriteFile(path, []byte("plain text"), 0o644))
got, err := Text(path)
require.NoError(t, err)
assert.Equal(t, "plain text", got)
}
func TestText_UnsupportedExtension(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "data.csv")
require.NoError(t, os.WriteFile(path, []byte("a,b,c"), 0o644))
_, err := Text(path)
assert.ErrorContains(t, err, "unsupported")
}
func TestText_PDF(t *testing.T) {
if _, err := exec.LookPath("pdftotext"); err != nil {
t.Skip("pdftotext not available")
}
dir := t.TempDir()
pdfPath := filepath.Join(dir, "test.pdf")
// Minimal valid PDF containing the text "Hello PDF".
minimalPDF := "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>>>endobj\n" +
"4 0 obj<</Length 44>>\nstream\nBT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\nendstream\nendobj\n" +
"xref\n0 5\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\n0000000310 00000 n\n" +
"trailer<</Size 5/Root 1 0 R>>\nstartxref\n406\n%%EOF\n"
require.NoError(t, os.WriteFile(pdfPath, []byte(minimalPDF), 0o644))
got, err := Text(pdfPath)
require.NoError(t, err)
assert.Contains(t, got, "Hello PDF")
}

View File

@@ -0,0 +1,28 @@
// ingestion/internal/extract/pdf.go
package extract
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// extractPDF runs pdftotext on path and returns the extracted text.
// pdftotext must be installed (package: poppler-utils on Alpine/Debian, poppler on Homebrew).
func extractPDF(path string) (string, error) {
cmd := exec.Command("pdftotext", "-q", path, "-")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return "", fmt.Errorf("pdftotext: %s", errMsg)
}
return strings.TrimSpace(stdout.String()), nil
}

View File

@@ -0,0 +1,263 @@
// Package graph extracts entity + edge records from brain markdown
// documents for the brain_entities / brain_edges relational graph.
//
// The extractor is pure: it takes markdown bytes and a document path and
// returns the entity (one per doc) and the wikilink edges (zero or more)
// it found, with source line numbers so the graph store can record
// provenance.
//
// Edge types in v1: only "wikilink" — derived from [[slug]] and
// [[slug|Display]] occurrences in the body. Section-header edges are
// deferred (see infra#62 grill addendum).
package graph
import (
"bufio"
"bytes"
"path/filepath"
"regexp"
"strings"
)
// Entity represents one brain document for graph indexing.
//
// Slug is the basename without ".md" — the same identity used by
// wiki canonicalization and the wikilink target syntax.
//
// Type categorises the doc into a coarse bucket so callers can filter
// graph traversals (e.g. "only entity nodes"). When the doc lives
// under brain/wiki/<wing>/<hall>/, Wing and Hall capture the
// taxonomy; otherwise they're empty (legacy brain/knowledge/ docs).
type Entity struct {
DocPath string // forward-slash, relative to brainDir
Slug string
Type string // "concept" | "entity" | "source" | "hall" | "knowledge"
Wing string // optional; from frontmatter or path
Hall string // optional; from frontmatter or path
Title string // optional; from frontmatter
// DIKW tier — infra#72. Empty until M3 migration writes `tier:`
// frontmatter to every entry. Path-inferred tier kicks in as a
// fallback so the column populates immediately on backfill even
// for entries that haven't had their frontmatter rewritten yet.
Tier string // "inbox" | "note" | "knowledge"
Topic string // kebab-slug; the thing the entry is about
}
// Edge represents a directed relationship between two slugs.
//
// SrcLine is the 1-indexed line in the source document where the link
// was found, so callers can re-find the linking text after an edit.
type Edge struct {
SrcDoc string // forward-slash, relative to brainDir
SrcSlug string // == Entity.Slug for SrcDoc
DstSlug string
EdgeType string // "wikilink" in v1
SrcLine int // 1-indexed
}
// linkRE matches both [[slug]] and [[slug|Display Name]] wikilinks.
// Group 1 is the slug; group 2 (if present) is the display.
var linkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
// Extract parses one markdown document and returns its Entity plus the
// outgoing wikilink Edges. docPath is forward-slash, relative to
// brainDir; content is the raw markdown bytes.
//
// Returns ok=false when docPath does not yield a usable slug (e.g.
// non-markdown file slipped through).
func Extract(docPath string, content []byte) (Entity, []Edge, bool) {
slug := slugFromPath(docPath)
if slug == "" {
return Entity{}, nil, false
}
ent := Entity{DocPath: docPath, Slug: slug}
classifyByPath(&ent, docPath)
readFrontmatter(&ent, content)
inferTierFromPath(&ent, docPath)
edges := extractEdges(docPath, slug, content)
return ent, edges, true
}
// inferTierFromPath fills Tier when frontmatter didn't already set it.
// The new layout has dedicated subtrees per tier; pre-migration paths
// (knowledge/, wiki/, raw/, sessions/) get their best-guess mapping so
// the column populates on backfill before the M3 file moves run.
func inferTierFromPath(e *Entity, docPath string) {
if e.Tier != "" {
return
}
parts := strings.Split(docPath, "/")
if len(parts) == 0 {
return
}
switch parts[0] {
case "inbox":
e.Tier = "inbox"
case "notes":
e.Tier = "note"
case "knowledge":
e.Tier = "knowledge"
case "wiki":
// Pre-M3 wiki layout. Most subdirs are I-level:
// wiki/sources/ — synth summaries of raw inbox material
// wiki/concepts/ — definitions, not lessons
// One exception: wiki/entities/ holds anchor facts about
// concrete things (models, services, people) that the eval
// expects to surface when queried directly. Those map to K
// to match the post-M3 layout target (knowledge/facts/).
if len(parts) >= 2 && parts[1] == "entities" {
e.Tier = "knowledge"
} else {
e.Tier = "note"
}
case "raw", "sessions", "clips":
e.Tier = "inbox"
}
}
func slugFromPath(docPath string) string {
base := filepath.Base(docPath)
if !strings.HasSuffix(base, ".md") {
return ""
}
return strings.TrimSuffix(base, ".md")
}
// classifyByPath fills Type / Wing / Hall from the path layout when the
// doc lives under brain/wiki/. Layout: wiki/<wing>/<hall>/<slug>.md
// or wiki/<bucket>/<slug>.md for the legacy concept/entity/source dirs.
//
// Files directly under wiki/ (no subdirectory — e.g. wiki/index.md) used
// to incorrectly land Type="hall" Wing="index.md" because the path's
// second segment was the file itself. Now they fall through to Type
// "knowledge" and leave wing/hall to frontmatter.
func classifyByPath(e *Entity, docPath string) {
parts := strings.Split(docPath, "/")
if len(parts) < 2 || parts[0] != "wiki" {
e.Type = "knowledge"
return
}
if len(parts) < 3 {
// wiki/<slug>.md — no subdirectory. Treat as plain knowledge
// and let frontmatter set wing/hall if they're present.
e.Type = "knowledge"
return
}
switch parts[1] {
case "concepts":
e.Type = "concept"
case "entities":
e.Type = "entity"
case "sources":
e.Type = "source"
default:
// wiki/<wing>/<hall>/<slug>.md
e.Type = "hall"
e.Wing = parts[1]
if len(parts) >= 4 {
e.Hall = parts[2]
}
}
}
// readFrontmatter pulls title/wing/hall from a YAML frontmatter block.
// Frontmatter is optional; missing fields leave the entity unchanged.
func readFrontmatter(e *Entity, content []byte) {
scanner := bufio.NewScanner(bytes.NewReader(content))
inFM := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "---" {
if !inFM {
inFM = true
continue
}
return
}
if !inFM {
return
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
v := strings.Trim(strings.TrimSpace(val), `"'`)
switch strings.TrimSpace(key) {
case "title":
if e.Title == "" {
e.Title = v
}
case "wing":
if e.Wing == "" {
e.Wing = v
}
case "hall":
if e.Hall == "" {
e.Hall = v
}
case "tier":
if e.Tier == "" {
e.Tier = v
}
case "topic":
if e.Topic == "" {
e.Topic = v
}
}
}
}
func extractEdges(docPath, srcSlug string, content []byte) []Edge {
var edges []Edge
seen := make(map[string]struct{}) // dedupe (dst, line)
scanner := bufio.NewScanner(bytes.NewReader(content))
line := 0
for scanner.Scan() {
line++
matches := linkRE.FindAllStringSubmatch(scanner.Text(), -1)
for _, m := range matches {
dst := strings.TrimSpace(m[1])
if dst == "" || dst == srcSlug {
continue
}
key := dst + "|" + itoa(line)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
edges = append(edges, Edge{
SrcDoc: docPath,
SrcSlug: srcSlug,
DstSlug: dst,
EdgeType: "wikilink",
SrcLine: line,
})
}
}
return edges
}
// itoa avoids the fmt dependency on a hot path. Single-digit fast path
// keeps overhead negligible for typical line counts.
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

View File

@@ -0,0 +1,179 @@
package graph
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtract_HallDoc(t *testing.T) {
content := []byte(`---
wing: jepa-fx
hall: decisions
title: Val Vol Decision
---
# Val Vol
See also [[other-decision]] and [[parent-concept|Parent Concept]].
Linking to [[unrelated]].
`)
ent, edges, ok := Extract("wiki/jepa-fx/decisions/val-vol.md", content)
require.True(t, ok)
assert.Equal(t, "val-vol", ent.Slug)
assert.Equal(t, "hall", ent.Type)
assert.Equal(t, "jepa-fx", ent.Wing)
assert.Equal(t, "decisions", ent.Hall)
assert.Equal(t, "Val Vol Decision", ent.Title)
require.Len(t, edges, 3)
assert.Equal(t, "other-decision", edges[0].DstSlug)
assert.Equal(t, "parent-concept", edges[1].DstSlug)
assert.Equal(t, "unrelated", edges[2].DstSlug)
for _, e := range edges {
assert.Equal(t, "wikilink", e.EdgeType)
assert.Equal(t, "val-vol", e.SrcSlug)
assert.Equal(t, "wiki/jepa-fx/decisions/val-vol.md", e.SrcDoc)
assert.Greater(t, e.SrcLine, 0)
}
}
func TestExtract_LegacyConceptDoc(t *testing.T) {
content := []byte(`---
title: Hash Encoding
---
# Hash Encoding
Linked to [[financial-sentiment-analysis|FSA]].
`)
ent, edges, ok := Extract("wiki/concepts/hash-encoding.md", content)
require.True(t, ok)
assert.Equal(t, "hash-encoding", ent.Slug)
assert.Equal(t, "concept", ent.Type)
assert.Empty(t, ent.Wing)
assert.Empty(t, ent.Hall)
assert.Equal(t, "Hash Encoding", ent.Title)
require.Len(t, edges, 1)
assert.Equal(t, "financial-sentiment-analysis", edges[0].DstSlug)
}
func TestExtract_KnowledgeDoc(t *testing.T) {
content := []byte("# No frontmatter, no links here.\n")
ent, edges, ok := Extract("knowledge/some-note.md", content)
require.True(t, ok)
assert.Equal(t, "some-note", ent.Slug)
assert.Equal(t, "knowledge", ent.Type)
assert.Empty(t, edges)
}
func TestExtract_DedupesRepeatedLinkOnSameLine(t *testing.T) {
content := []byte("See [[foo]] and [[foo]] again on the same line.\n")
_, edges, ok := Extract("knowledge/dup.md", content)
require.True(t, ok)
require.Len(t, edges, 1)
assert.Equal(t, "foo", edges[0].DstSlug)
}
func TestExtract_KeepsMultipleEdgesOnDifferentLines(t *testing.T) {
content := []byte("First mention [[foo]].\n\nSecond mention [[foo]].\n")
_, edges, ok := Extract("knowledge/multi.md", content)
require.True(t, ok)
require.Len(t, edges, 2)
assert.NotEqual(t, edges[0].SrcLine, edges[1].SrcLine)
}
func TestExtract_IgnoresSelfLinks(t *testing.T) {
content := []byte("Self-reference [[self]] should be ignored.\n")
_, edges, ok := Extract("knowledge/self.md", content)
require.True(t, ok)
assert.Empty(t, edges)
}
func TestExtract_RejectsNonMarkdown(t *testing.T) {
_, _, ok := Extract("wiki/concepts/not-markdown.txt", []byte("anything"))
assert.False(t, ok)
}
func TestExtract_LineNumbersAre1Indexed(t *testing.T) {
content := []byte("line 1\nline 2 [[bar]]\n")
_, edges, ok := Extract("knowledge/lines.md", content)
require.True(t, ok)
require.Len(t, edges, 1)
assert.Equal(t, 2, edges[0].SrcLine)
}
// Files directly under wiki/ (no subdirectory) used to land
// Type="hall" Wing="<filename>.md" because the path's second segment
// was the file itself. The fix routes them to Type="knowledge" with
// empty Wing/Hall and lets frontmatter set them if present.
func TestExtract_WikiRootFileIsKnowledgeNotHall(t *testing.T) {
content := []byte("# Index\n\n- [[foo]]\n")
ent, _, ok := Extract("wiki/index.md", content)
require.True(t, ok)
assert.Equal(t, "index", ent.Slug)
assert.Equal(t, "knowledge", ent.Type)
assert.Empty(t, ent.Wing)
assert.Empty(t, ent.Hall)
}
func TestExtract_TierFromFrontmatter(t *testing.T) {
content := []byte(`---
tier: knowledge
topic: postgres-roles
title: Least-privilege migration trap
---
# body
`)
ent, _, ok := Extract("knowledge/some-lesson.md", content)
require.True(t, ok)
assert.Equal(t, "knowledge", ent.Tier)
assert.Equal(t, "postgres-roles", ent.Topic)
}
func TestExtract_TierInferredFromPath(t *testing.T) {
cases := []struct {
path string
want string
}{
{"knowledge/foo.md", "knowledge"},
{"wiki/sources/x.md", "note"},
{"wiki/concepts/x.md", "note"},
{"wiki/x.md", "note"},
{"inbox/clips/x.md", "inbox"},
{"notes/x.md", "note"},
{"raw/x.md", "inbox"},
{"sessions/x.md", "inbox"},
}
for _, tc := range cases {
ent, _, ok := Extract(tc.path, []byte("# x\n"))
require.True(t, ok, tc.path)
assert.Equal(t, tc.want, ent.Tier, tc.path)
}
}
func TestExtract_FrontmatterTierBeatsPathInference(t *testing.T) {
// A clip explicitly promoted via frontmatter wins over the path's
// inbox inference. Catches the case where a file has been moved
// to a new location but frontmatter hasn't been updated.
content := []byte("---\ntier: knowledge\n---\n# x\n")
ent, _, ok := Extract("inbox/clips/x.md", content)
require.True(t, ok)
assert.Equal(t, "knowledge", ent.Tier)
}
func TestExtract_WikiRootFileWithFrontmatterWingHall(t *testing.T) {
content := []byte(`---
wing: homelab
hall: facts
---
# Some root note
`)
ent, _, ok := Extract("wiki/some-note.md", content)
require.True(t, ok)
assert.Equal(t, "knowledge", ent.Type)
assert.Equal(t, "homelab", ent.Wing)
assert.Equal(t, "facts", ent.Hall)
}

View File

@@ -0,0 +1,365 @@
// Package graphstore stores the brain knowledge graph (entities +
// directed edges) in PostgreSQL on the shared postgres18 instance,
// alongside the pgvector embeddings in [vectorstore].
//
// Schema (created idempotently by Init):
//
// brain_entities(slug PK, type, wing, hall, doc_path, title, updated_at)
// brain_edges(id PK, src_slug FK, dst_slug, edge_type, src_doc, src_line,
// weight, updated_at)
//
// Edges fan-out from a source document; calling [PGStore.ReplaceEdgesForDoc]
// replaces every edge previously emitted from that document so re-ingest is
// idempotent without bookkeeping.
//
// All slug strings are stored verbatim — callers are expected to canonicalise
// before persisting. Dst slugs may reference entities that don't yet exist
// (dangling edges); resolution is deferred to query time so ingestion order
// doesn't matter.
package graphstore
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
)
// PGStore is the postgres-backed brain knowledge-graph store. Construct
// with New + call Init once to create tables and indexes. Use Close to
// release the pool.
type PGStore struct {
pool *pgxpool.Pool
}
// New opens a pgxpool against dsn and pings to verify connectivity. The
// caller owns the resulting PGStore and must invoke Close.
func New(ctx context.Context, dsn string) (*PGStore, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("pgxpool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping: %w", err)
}
return &PGStore{pool: pool}, nil
}
// Close releases the underlying connection pool.
func (s *PGStore) Close() {
if s.pool != nil {
s.pool.Close()
}
}
// Init creates brain_entities + brain_edges tables and their indexes if
// they don't yet exist. Safe to call on every startup. No-op when the
// schema already matches.
func (s *PGStore) Init(ctx context.Context) error {
const ddl = `
CREATE TABLE IF NOT EXISTS brain_entities (
slug TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT 'knowledge',
wing TEXT NOT NULL DEFAULT '',
hall TEXT NOT NULL DEFAULT '',
doc_path TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT '',
topic TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Idempotent migration for clusters created before the DIKW tier
-- redesign (infra#72). ADD COLUMN IF NOT EXISTS is safe across
-- repeated startups.
ALTER TABLE brain_entities
ADD COLUMN IF NOT EXISTS tier TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS topic TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS brain_entities_wing_idx
ON brain_entities (wing) WHERE wing <> '';
CREATE INDEX IF NOT EXISTS brain_entities_type_idx
ON brain_entities (type);
CREATE INDEX IF NOT EXISTS brain_entities_tier_idx
ON brain_entities (tier) WHERE tier <> '';
CREATE INDEX IF NOT EXISTS brain_entities_topic_idx
ON brain_entities (topic) WHERE topic <> '';
CREATE TABLE IF NOT EXISTS brain_edges (
id BIGSERIAL PRIMARY KEY,
src_slug TEXT NOT NULL,
dst_slug TEXT NOT NULL,
edge_type TEXT NOT NULL DEFAULT 'wikilink',
src_doc TEXT NOT NULL,
src_line INTEGER NOT NULL DEFAULT 0,
weight REAL NOT NULL DEFAULT 1.0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS brain_edges_src_idx
ON brain_edges (src_slug, edge_type);
CREATE INDEX IF NOT EXISTS brain_edges_dst_idx
ON brain_edges (dst_slug, edge_type);
CREATE INDEX IF NOT EXISTS brain_edges_src_doc_idx
ON brain_edges (src_doc);
`
_, err := s.pool.Exec(ctx, ddl)
return err
}
// UpsertEntity inserts or updates one entity by slug.
func (s *PGStore) UpsertEntity(ctx context.Context, e graph.Entity) error {
if e.Slug == "" {
return errors.New("entity slug is required")
}
if e.Type == "" {
e.Type = "knowledge"
}
_, err := s.pool.Exec(ctx, `
INSERT INTO brain_entities (slug, type, wing, hall, doc_path, title, tier, topic, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (slug) DO UPDATE
SET type = EXCLUDED.type,
wing = EXCLUDED.wing,
hall = EXCLUDED.hall,
doc_path = EXCLUDED.doc_path,
title = EXCLUDED.title,
tier = EXCLUDED.tier,
topic = EXCLUDED.topic,
updated_at = now()
`, e.Slug, e.Type, e.Wing, e.Hall, e.DocPath, e.Title, e.Tier, e.Topic)
if err != nil {
return fmt.Errorf("upsert entity %q: %w", e.Slug, err)
}
return nil
}
// ReplaceEdgesForDoc deletes every edge previously emitted from docPath
// and inserts the new set in one transaction. Caller should pass the
// complete edge set for the doc — partial updates are not supported.
func (s *PGStore) ReplaceEdgesForDoc(ctx context.Context, docPath string, edges []graph.Edge) error {
if docPath == "" {
return errors.New("doc path is required")
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `DELETE FROM brain_edges WHERE src_doc = $1`, docPath); err != nil {
return fmt.Errorf("delete prior edges for %q: %w", docPath, err)
}
for _, e := range edges {
if e.SrcSlug == "" || e.DstSlug == "" {
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO brain_edges (src_slug, dst_slug, edge_type, src_doc, src_line, weight)
VALUES ($1, $2, $3, $4, $5, 1.0)
`, e.SrcSlug, e.DstSlug, e.EdgeType, e.SrcDoc, e.SrcLine); err != nil {
return fmt.Errorf("insert edge %s->%s: %w", e.SrcSlug, e.DstSlug, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
// DeleteByDoc removes the entity at docPath and every edge it sourced.
// Use when a wiki page is deleted on disk.
func (s *PGStore) DeleteByDoc(ctx context.Context, docPath string) error {
if docPath == "" {
return errors.New("doc path is required")
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `DELETE FROM brain_edges WHERE src_doc = $1`, docPath); err != nil {
return fmt.Errorf("delete edges: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM brain_entities WHERE doc_path = $1`, docPath); err != nil {
return fmt.Errorf("delete entity: %w", err)
}
return tx.Commit(ctx)
}
// Neighbor is one row in a Neighbors / Subgraph response.
type Neighbor struct {
Slug string
Type string
Wing string
Hall string
DocPath string
Title string
EdgeType string
Distance int // hop count from origin; 1 for direct neighbors
}
// Neighbors returns the direct (1-hop) outgoing neighbours of slug.
// edgeType filters by relationship kind; "" returns all kinds.
// limit defaults to 25 when <= 0.
func (s *PGStore) Neighbors(ctx context.Context, slug, edgeType string, limit int) ([]Neighbor, error) {
if slug == "" {
return nil, errors.New("slug is required")
}
if limit <= 0 {
limit = 25
}
q := `
SELECT e.dst_slug, COALESCE(t.type,''), COALESCE(t.wing,''), COALESCE(t.hall,''),
COALESCE(t.doc_path,''), COALESCE(t.title,''), e.edge_type, 1
FROM brain_edges e
LEFT JOIN brain_entities t ON t.slug = e.dst_slug
WHERE e.src_slug = $1
AND ($2 = '' OR e.edge_type = $2)
ORDER BY e.updated_at DESC
LIMIT $3
`
rows, err := s.pool.Query(ctx, q, slug, edgeType, limit)
if err != nil {
return nil, fmt.Errorf("query neighbors: %w", err)
}
defer rows.Close()
return scanNeighbors(rows)
}
// Subgraph returns every distinct slug reachable from origin within
// depth outgoing hops, annotated with the shortest hop distance. The
// origin itself is omitted. depth defaults to 2 when <= 0; values
// above 6 are clamped to 6 to bound traversal cost.
func (s *PGStore) Subgraph(ctx context.Context, origin string, depth int) ([]Neighbor, error) {
if origin == "" {
return nil, errors.New("origin slug is required")
}
if depth <= 0 {
depth = 2
}
if depth > 6 {
depth = 6
}
q := `
WITH RECURSIVE walk(slug, edge_type, distance) AS (
SELECT e.dst_slug, e.edge_type, 1
FROM brain_edges e
WHERE e.src_slug = $1
UNION
SELECT e.dst_slug, e.edge_type, w.distance + 1
FROM walk w
JOIN brain_edges e ON e.src_slug = w.slug
WHERE w.distance < $2
)
SELECT w.slug, COALESCE(t.type,''), COALESCE(t.wing,''), COALESCE(t.hall,''),
COALESCE(t.doc_path,''), COALESCE(t.title,''), w.edge_type, MIN(w.distance)
FROM walk w
LEFT JOIN brain_entities t ON t.slug = w.slug
WHERE w.slug <> $1
GROUP BY w.slug, t.type, t.wing, t.hall, t.doc_path, t.title, w.edge_type
ORDER BY MIN(w.distance), w.slug
`
rows, err := s.pool.Query(ctx, q, origin, depth)
if err != nil {
return nil, fmt.Errorf("query subgraph: %w", err)
}
defer rows.Close()
return scanNeighbors(rows)
}
// PathStep is one hop in a Path response.
type PathStep struct {
FromSlug string
ToSlug string
EdgeType string
}
// Path returns the shortest directed path from src to dst within
// maxDepth hops, as an ordered list of edges. Empty slice means no
// path exists. maxDepth defaults to 4 when <= 0; values above 8 are
// clamped to 8.
func (s *PGStore) Path(ctx context.Context, src, dst string, maxDepth int) ([]PathStep, error) {
if src == "" || dst == "" {
return nil, errors.New("src and dst are required")
}
if maxDepth <= 0 {
maxDepth = 4
}
if maxDepth > 8 {
maxDepth = 8
}
q := `
WITH RECURSIVE walk(cur, path_slugs, path_edges, distance) AS (
SELECT e.dst_slug,
ARRAY[e.src_slug, e.dst_slug]::TEXT[],
ARRAY[e.edge_type]::TEXT[],
1
FROM brain_edges e
WHERE e.src_slug = $1
UNION ALL
SELECT e.dst_slug,
w.path_slugs || e.dst_slug,
w.path_edges || e.edge_type,
w.distance + 1
FROM walk w
JOIN brain_edges e ON e.src_slug = w.cur
WHERE w.distance < $3
AND NOT (e.dst_slug = ANY(w.path_slugs))
)
SELECT path_slugs, path_edges
FROM walk
WHERE cur = $2
ORDER BY distance ASC
LIMIT 1
`
row := s.pool.QueryRow(ctx, q, src, dst, maxDepth)
var (
slugs []string
kinds []string
)
if err := row.Scan(&slugs, &kinds); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scan path: %w", err)
}
if len(slugs) < 2 || len(kinds) == 0 {
return nil, nil
}
steps := make([]PathStep, 0, len(kinds))
for i := 0; i < len(kinds) && i+1 < len(slugs); i++ {
steps = append(steps, PathStep{
FromSlug: slugs[i],
ToSlug: slugs[i+1],
EdgeType: kinds[i],
})
}
return steps, nil
}
// CountEdges is a debug helper — returns the total edges currently stored.
// Used by tests and by the volume-gate diagnostic.
func (s *PGStore) CountEdges(ctx context.Context) (int64, error) {
var n int64
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM brain_edges`).Scan(&n)
return n, err
}
func scanNeighbors(rows pgx.Rows) ([]Neighbor, error) {
var out []Neighbor
for rows.Next() {
var n Neighbor
if err := rows.Scan(
&n.Slug, &n.Type, &n.Wing, &n.Hall,
&n.DocPath, &n.Title, &n.EdgeType, &n.Distance,
); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,112 @@
// Package graphsync glues the disk-resident brain markdown documents to
// the relational graph in [graphstore]. It is a tiny seam so that the
// MCP handlers can call one function after every successful write or
// ingest without having to know either the parser or the postgres
// schema.
//
// Every operation is best-effort from the caller's perspective: if the
// graph store is unconfigured or the doc parses to nothing usable, the
// helpers return nil. Real database errors are surfaced so the caller
// can log them.
package graphsync
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
)
// Store is the subset of graphstore.PGStore that graphsync requires.
// Tests can substitute a fake by satisfying this interface.
type Store interface {
UpsertEntity(ctx context.Context, e graph.Entity) error
ReplaceEdgesForDoc(ctx context.Context, docPath string, edges []graph.Edge) error
DeleteByDoc(ctx context.Context, docPath string) error
}
// Compile-time assertion that *graphstore.PGStore satisfies Store.
var _ Store = (*graphstore.PGStore)(nil)
// IndexDoc reads docPath under brainDir and pushes one Entity + its
// outgoing wikilink Edges into store. relPath must be the
// forward-slash path relative to brainDir (the same shape returned by
// api.WriteNote).
//
// nil store is a valid no-op so callers can wire the helper
// unconditionally and let configuration decide whether the graph is
// populated.
func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error {
if store == nil {
return nil
}
if relPath == "" {
return nil
}
abs := filepath.Join(brainDir, filepath.FromSlash(relPath))
content, err := os.ReadFile(abs)
if err != nil {
return fmt.Errorf("read %q: %w", relPath, err)
}
ent, edges, ok := graph.Extract(relPath, content)
if !ok {
return nil
}
if err := store.UpsertEntity(ctx, ent); err != nil {
return fmt.Errorf("upsert entity: %w", err)
}
if err := store.ReplaceEdgesForDoc(ctx, relPath, edges); err != nil {
return fmt.Errorf("replace edges: %w", err)
}
return nil
}
// BackfillFromBrainDir walks every markdown file under brainDir/wiki/
// and brainDir/knowledge/, parses each, and upserts the resulting
// Entity + Edges. Existing rows are overwritten; orphan rows for
// already-deleted files are NOT cleaned up — call this only on a
// fresh store, or follow with a separate prune pass.
//
// Intended for one-shot startup runs against a populated brain dir.
// Cost scales linearly with corpus size; ~30 wiki pages plus the
// knowledge corpus is a few hundred ms.
func BackfillFromBrainDir(ctx context.Context, store Store, brainDir string) (indexed int, _ error) {
if store == nil {
return 0, nil
}
roots := []string{"wiki", "knowledge"}
for _, root := range roots {
base := filepath.Join(brainDir, root)
if _, err := os.Stat(base); os.IsNotExist(err) {
continue
}
err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
rel, relErr := filepath.Rel(brainDir, path)
if relErr != nil {
return fmt.Errorf("rel %q: %w", path, relErr)
}
rel = filepath.ToSlash(rel)
if err := IndexDoc(ctx, store, brainDir, rel); err != nil {
return fmt.Errorf("index %q: %w", rel, err)
}
indexed++
return nil
})
if err != nil {
return indexed, fmt.Errorf("walk %s: %w", root, err)
}
}
return indexed, nil
}

Some files were not shown because too many files have changed in this diff Show More