Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5a9cfce3 | ||
|
|
25b79f057e | ||
|
|
47bbac4b9f | ||
|
|
8a27ead91c | ||
|
|
1a9be0cfb1 | ||
|
|
dc2265da0b | ||
|
|
c176b71b3d | ||
|
|
4e8bdc137e | ||
|
|
42235dcb6a | ||
|
|
05330ad54a | ||
|
|
692244d213 | ||
|
|
d98a305985 | ||
|
|
670325e519 |
@@ -7,6 +7,29 @@
|
||||
"supervisor": {
|
||||
"url": "http://localhost:3200/mcp",
|
||||
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
||||
},
|
||||
"brain": {
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||
}
|
||||
},
|
||||
"gitea": {
|
||||
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||
}
|
||||
},
|
||||
"infra": {
|
||||
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
-19
@@ -53,14 +53,30 @@ These rules apply to every task across every project, regardless of harness.
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||
the tiered merge model in `assessor-loop`
|
||||
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||
what it sells instead of merging everything on green and claiming otherwise.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||
collisions between parallel sessions: two were not branching problems at all — a
|
||||
generated artifact written to the same path by both (branching turns that into a
|
||||
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||
timestamp generated artifacts. Do not reach for a branch.
|
||||
|
||||
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||
issues, review comments live on a thread separate from the issue's, and a
|
||||
merged-and-deleted branch cannot be amended (brain:
|
||||
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
@@ -78,6 +94,18 @@ These rules apply to every task across every project, regardless of harness.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||
Every gap, caveat, risk, open question and next step must land in one of four
|
||||
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||
remembered convention is an unreliable control.
|
||||
|
||||
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -148,10 +176,10 @@ Three machines on Tailscale:
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||
|
||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||
- **Orchestration**: k3s cluster across all three machines
|
||||
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||
- **Networking**: Tailscale mesh
|
||||
|
||||
## Project landscape
|
||||
@@ -247,10 +275,11 @@ When working on a project tagged with a client name:
|
||||
|
||||
## Harness-agnostic principles
|
||||
|
||||
This context is designed to work with any AI coding tool:
|
||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||
- Any tool that accepts a system prompt or reads a markdown context file
|
||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||
re-add adapters for them:
|
||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||
|
||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
@@ -262,15 +291,29 @@ Canonical sources of truth:
|
||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||
|
||||
Derived files (committed, regenerated by `task context:sync`):
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||
`.context/system-prompt.txt`
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||
|
||||
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||
you touch the repo, along with their entries in that repo's `task check`
|
||||
assertion.
|
||||
|
||||
Workflow:
|
||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||
derived together. Push.
|
||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||
up** from cwd and takes the first `AGENTS.md` it finds
|
||||
(user-level `~/.vibe/AGENTS.md`)
|
||||
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||
any harness, it is for pasting
|
||||
|
||||
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||
inside that repo, so this works — but by construction, not by luck.
|
||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||
is empty over the derived files (catches both modified-tracked drift
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
@@ -281,7 +324,20 @@ unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||
|
||||
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||
|
||||
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ jobs:
|
||||
set -e
|
||||
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||
IMAGE_TAG="${{ github.sha }}"
|
||||
# infra#311: github.sha is never empty in practice, but the manifest
|
||||
# patch below is the same shape that took Flux down estate-wide on
|
||||
# 2026-08-25 -- an empty tag yields `image: <repo>:`, which is invalid
|
||||
# YAML and fails the ENTIRE Kustomization, not just this app. The
|
||||
# assert costs nothing and removes the class.
|
||||
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||
chmod 600 ~/.ssh/infra_deploy_key
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit secret scan for the hyperguild repo.
|
||||
#
|
||||
# task check -> security:gitleaks enforces this in CI, but CI is after the
|
||||
# push. This catches a staged secret before it ever leaves the machine --
|
||||
# the infra#39 rotation pass found exactly this class of leak (a printed
|
||||
# credential, committed, archived into git) and there was no local gate to
|
||||
# have caught it. Mirrors infra's own .githooks/pre-commit (infra#326).
|
||||
#
|
||||
# Enable once per clone: git config core.hooksPath .githooks
|
||||
# Bypass if absolutely needed: git commit --no-verify
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GITLEAKS="$(command -v gitleaks || true)"
|
||||
if [[ -z "$GITLEAKS" && -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ]]; then
|
||||
GITLEAKS="$(go env GOPATH)/bin/gitleaks"
|
||||
fi
|
||||
|
||||
if [[ -z "$GITLEAKS" ]]; then
|
||||
echo "⚠ gitleaks not installed — skipping local secret scan (CI still enforces it)."
|
||||
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "→ Scanning staged changes for secrets..."
|
||||
if ! "$GITLEAKS" protect --staged --redact --config .gitleaks.toml >/dev/null 2>&1; then
|
||||
echo " ✗ gitleaks found a secret in the staged changes."
|
||||
echo " Review with: gitleaks protect --staged --redact --config .gitleaks.toml -v"
|
||||
echo " If it is a false positive, allowlist it in .gitleaks.toml or add a"
|
||||
echo " '# gitleaks:allow' comment. Do NOT --no-verify a real secret."
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ no staged secrets"
|
||||
+13
-5
@@ -56,8 +56,16 @@ paths = [
|
||||
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||
]
|
||||
|
||||
[[allowlists]]
|
||||
description = "Literal placeholder word REDACTED matched as if it were a token (verified 2026-08-04: extracted Secret == 'REDACTED' exactly, gitleaks' curl-auth-header rule matched the placeholder text itself, not a real credential)"
|
||||
condition = "AND"
|
||||
paths = ['''docs/superpowers/plans/2026-04-22-phase4-attempt-wiring\.md$''']
|
||||
regexes = ['''REDACTED''']
|
||||
# infra#344: the allowlist that used to sit here is gone, and so is the reason
|
||||
# for it. It suppressed docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
|
||||
# on the stated grounds that "extracted Secret == 'REDACTED' exactly ... the
|
||||
# placeholder text itself, not a real credential" (verified 2026-08-04). That was
|
||||
# not what the file contained: line 1054 carried a real 40-char hex Gitea PAT in
|
||||
# an `Authorization: token ...` header. Confirmed dead by side-effect — GET
|
||||
# /api/v1/user with it returns 401 — and redacted in that file, so there is now
|
||||
# nothing to allowlist.
|
||||
#
|
||||
# The `paths` entry also made the block far broader than its description: gitleaks
|
||||
# ORs global allowlist clauses and ignores `condition = "AND"`, so the whole file
|
||||
# was immune, not just lines matching /REDACTED/. A live token added to that doc
|
||||
# would have been suppressed on the strength of a note about a different value.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Historical gitleaks findings, dispositioned (infra#326 / infra#317).
|
||||
#
|
||||
# WHY THIS FILE AND NOT .gitleaks.toml:
|
||||
# This is a finding in git HISTORY, surfaced by the full-history audit
|
||||
# (`task security:gitleaks:history`). The working-tree scan that gates commits
|
||||
# (`task security:gitleaks`, the pre-commit hook, CI) is clean. Suppressing by
|
||||
# PATH in .gitleaks.toml would also blind the working-tree scan for that file
|
||||
# forever; a fingerprint pins one finding in one commit, so a NEW secret in the
|
||||
# same file still fires.
|
||||
#
|
||||
# History is not rewritten here, so the remediation for a real leak is
|
||||
# ROTATION -- making the committed value worthless -- never scrubbing.
|
||||
#
|
||||
# Dispositioned 2026-08-29. Re-check with: task security:gitleaks:history
|
||||
|
||||
# --- Real credential, ALREADY DEAD -------------------------------------------
|
||||
# A Gitea PAT pasted into a CD-watching command in a planning doc
|
||||
# (`Authorization: token <40-hex>`, last_eight 3105a670).
|
||||
#
|
||||
# Verified dead 2026-08-29: authenticating against
|
||||
# GET https://git.d-ma.be/api/v1/user with this exact value returns 401, so the
|
||||
# committed token no longer grants anything. It was almost certainly invalidated
|
||||
# by the early-August rotation of the `mathias` PATs (the same rotation whose
|
||||
# fan-out caused infra#260, where CI copies of the old value kept failing).
|
||||
#
|
||||
# No rotation action outstanding. Kept as a fingerprint rather than removed from
|
||||
# history because history cannot be rewritten here.
|
||||
#
|
||||
# The generalisable lesson is recorded separately: a credential printed into a
|
||||
# doc/transcript is permanent, so the only remedy is rotating it -- which in
|
||||
# this case had already happened for unrelated reasons, by luck rather than by
|
||||
# the leak being noticed.
|
||||
c9310b1079478bf4430b0a2dfb25445d7ccc9b59:docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md:curl-auth-header:1053
|
||||
@@ -48,14 +48,30 @@ These rules apply to every task across every project, regardless of harness.
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||
the tiered merge model in `assessor-loop`
|
||||
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||
what it sells instead of merging everything on green and claiming otherwise.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||
collisions between parallel sessions: two were not branching problems at all — a
|
||||
generated artifact written to the same path by both (branching turns that into a
|
||||
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||
timestamp generated artifacts. Do not reach for a branch.
|
||||
|
||||
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||
issues, review comments live on a thread separate from the issue's, and a
|
||||
merged-and-deleted branch cannot be amended (brain:
|
||||
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
@@ -73,6 +89,18 @@ These rules apply to every task across every project, regardless of harness.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||
Every gap, caveat, risk, open question and next step must land in one of four
|
||||
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||
remembered convention is an unreliable control.
|
||||
|
||||
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -143,10 +171,10 @@ Three machines on Tailscale:
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||
|
||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||
- **Orchestration**: k3s cluster across all three machines
|
||||
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||
- **Networking**: Tailscale mesh
|
||||
|
||||
## Project landscape
|
||||
@@ -242,10 +270,11 @@ When working on a project tagged with a client name:
|
||||
|
||||
## Harness-agnostic principles
|
||||
|
||||
This context is designed to work with any AI coding tool:
|
||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||
- Any tool that accepts a system prompt or reads a markdown context file
|
||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||
re-add adapters for them:
|
||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||
|
||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
@@ -257,15 +286,29 @@ Canonical sources of truth:
|
||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||
|
||||
Derived files (committed, regenerated by `task context:sync`):
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||
`.context/system-prompt.txt`
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||
|
||||
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||
you touch the repo, along with their entries in that repo's `task check`
|
||||
assertion.
|
||||
|
||||
Workflow:
|
||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||
derived together. Push.
|
||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||
up** from cwd and takes the first `AGENTS.md` it finds
|
||||
(user-level `~/.vibe/AGENTS.md`)
|
||||
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||
any harness, it is for pasting
|
||||
|
||||
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||
inside that repo, so this works — but by construction, not by luck.
|
||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||
is empty over the derived files (catches both modified-tracked drift
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
@@ -276,7 +319,20 @@ unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||
|
||||
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||
|
||||
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
|
||||
+20
-1
@@ -86,8 +86,26 @@ tasks:
|
||||
check:
|
||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||
cmds:
|
||||
- task: context:sync
|
||||
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
||||
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
||||
# the tree for the canonical .context/AGENT.md, which lives in local-dev
|
||||
# and does not exist on a CI runner. Without this guard, the script
|
||||
# silently falls back to "project context only" and regenerates AGENTS.md
|
||||
# and .context/system-prompt.txt shorter than their committed, correct
|
||||
# versions -- so the drift assertion fails every CI run for a purely
|
||||
# environmental reason, not real drift. Verified, not assumed: this is
|
||||
# exactly what was happening here (AGENTS.md 430 lines committed vs. an
|
||||
# 82-line regeneration on the runner).
|
||||
#
|
||||
# The cost is real and worth stating: drift is therefore only ever caught
|
||||
# on a developer machine, or by infra's estate-wide context-sync-drift
|
||||
# CronJob (ADR-0019), which does have canonical access and is the actual
|
||||
# enforcement mechanism now.
|
||||
- cmd: |
|
||||
if [ -n "${CI:-}" ]; then
|
||||
echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
|
||||
else
|
||||
bash scripts/context-sync.sh
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||
if [ -n "$drift" ]; then
|
||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||
@@ -98,6 +116,7 @@ tasks:
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ context: canonical and adapters are in sync"
|
||||
fi
|
||||
- task: lint
|
||||
- task: test
|
||||
- task: vet
|
||||
|
||||
@@ -1051,7 +1051,7 @@ git push
|
||||
```bash
|
||||
# Poll until complete (replace RUN_ID with the new run):
|
||||
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
||||
-H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \
|
||||
-H "Authorization: token <REDACTED-dead-gitea-pat-infra#344>" \
|
||||
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
||||
```
|
||||
|
||||
|
||||
@@ -236,6 +236,11 @@ func main() {
|
||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||
}
|
||||
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
|
||||
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
|
||||
tertiaryKey := os.Getenv("GEMINI_API_KEY")
|
||||
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
|
||||
}
|
||||
answerComplete = router.Complete
|
||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||
}
|
||||
@@ -391,6 +396,29 @@ func main() {
|
||||
}
|
||||
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
||||
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
||||
|
||||
// Project directories excluded from ingestion entirely, as globs
|
||||
// against the project directory name — e.g.
|
||||
// "*cobalt-dingo*,*claude-worktrees-bridge-cse*".
|
||||
//
|
||||
// This is a veto list, deliberately named as one. It complements the
|
||||
// client-name scrubber rather than replacing it: the scrubber drops
|
||||
// individual TURNS whose content matches, while this never opens a
|
||||
// project's files at all. Sessions working on live company financials
|
||||
// are excluded here because their content is confidential in ways no
|
||||
// pattern reliably recognises, and their learnings are captured
|
||||
// deliberately through the session-close ritual instead.
|
||||
//
|
||||
// Caveat worth knowing: a project directory is derived from the
|
||||
// session's WORKING DIRECTORY, not from the repositories it touches.
|
||||
// A session run from ~/dev that happens to read a skipped repo will
|
||||
// NOT be excluded by this. The scrubber remains the control for
|
||||
// content; this is a control for whole workspaces.
|
||||
skipProjects := splitList(os.Getenv("CLAUDE_INGEST_SKIP_PROJECTS"))
|
||||
if len(skipProjects) > 0 {
|
||||
logger.Info("claudewatcher: projects excluded from ingestion", "patterns", skipProjects)
|
||||
}
|
||||
|
||||
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
||||
go func() {
|
||||
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
||||
@@ -400,6 +428,7 @@ func main() {
|
||||
Sink: sink,
|
||||
Cursors: cursorStore,
|
||||
Logger: logger,
|
||||
SkipProjects: skipProjects,
|
||||
}); err != nil && err != context.Canceled {
|
||||
logger.Error("claudewatcher exited", "err", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package brain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// wikilinkRE matches [[target]] and [[target|Display Text]].
|
||||
var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`)
|
||||
|
||||
// fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a
|
||||
// syntax example rather than a link. Same exclusion brain-gardener's audit
|
||||
// applies on the read side, so the write gate and the audit agree on what
|
||||
// counts as a link at all.
|
||||
var (
|
||||
fencedCodeRE = regexp.MustCompile("(?s)```.*?```")
|
||||
inlineCodeRE = regexp.MustCompile("`[^`\n]*`")
|
||||
)
|
||||
|
||||
// StripUnresolvedLinks unwraps every [[wikilink]] in content whose target
|
||||
// resolves to no entry under knowledge/ or wiki/, leaving the link's display
|
||||
// text (or its target) as plain prose. It returns the rewritten content and
|
||||
// the sorted, de-duplicated targets it unwrapped.
|
||||
//
|
||||
// WHY UNWRAP RATHER THAN REJECT THE WRITE
|
||||
// Agents write [[...]] around anything that feels related — a skill name, a
|
||||
// repo, an agent-local memory file — and none of those are brain entries.
|
||||
// Every one of those becomes a permanent [high] broken-link finding that no
|
||||
// deterministic Fixer can repair, because there is no target to repoint to:
|
||||
// the brain-audit digest grew from 19 to 63 high findings between 2026-08-26
|
||||
// and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write
|
||||
// instead would push the failure onto claudewatcher's unattended ingest path,
|
||||
// where a refused write is a lost learning. Unwrapping keeps the author's
|
||||
// words, keeps the write, and keeps the graph honest.
|
||||
//
|
||||
// An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir)
|
||||
// returns content unchanged: nothing resolving is a bootstrap state, not
|
||||
// evidence that every target was invented.
|
||||
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
|
||||
index, err := entryIndex(brainDir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(index) == 0 {
|
||||
return content, nil, nil
|
||||
}
|
||||
|
||||
code := codeSpans(content)
|
||||
stripped := map[string]struct{}{}
|
||||
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) {
|
||||
start, end := m[0], m[1]
|
||||
if overlaps(start, end, code) {
|
||||
continue
|
||||
}
|
||||
target := strings.TrimSpace(content[m[2]:m[3]])
|
||||
if _, ok := index[linkKey(target)]; ok {
|
||||
continue
|
||||
}
|
||||
display := target
|
||||
if m[4] >= 0 {
|
||||
if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" {
|
||||
display = d
|
||||
}
|
||||
}
|
||||
b.WriteString(content[last:start])
|
||||
b.WriteString(display)
|
||||
last = end
|
||||
stripped[target] = struct{}{}
|
||||
}
|
||||
if last == 0 {
|
||||
return content, nil, nil
|
||||
}
|
||||
b.WriteString(content[last:])
|
||||
|
||||
targets := make([]string, 0, len(stripped))
|
||||
for t := range stripped {
|
||||
targets = append(targets, t)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
return b.String(), targets, nil
|
||||
}
|
||||
|
||||
// linkedTrees are the two trees that hold linkable entries — the same pair
|
||||
// package notes admits under ScopeNotes. Duplicated rather than imported
|
||||
// because that list is unexported there and this is a read-only mirror.
|
||||
var linkedTrees = []string{"knowledge", "wiki"}
|
||||
|
||||
// entryIndex maps every form a wikilink may name an entry by — its bare slug
|
||||
// and its path form (wiki/<wing>/<hall>/<slug>.md -> wing/hall/slug) — to a
|
||||
// present marker. Mirrors brain-gardener's resolver so an accepted link is
|
||||
// one the audit will also resolve.
|
||||
func entryIndex(brainDir string) (map[string]struct{}, error) {
|
||||
index := map[string]struct{}{}
|
||||
for _, dir := range linkedTrees {
|
||||
root := filepath.Join(brainDir, dir)
|
||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", dir, err)
|
||||
}
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(brainDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
|
||||
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// linkKey normalises a link target for index lookup: an exact path form is
|
||||
// tried as written, and anything else falls back to its final segment.
|
||||
func linkKey(target string) string {
|
||||
return strings.TrimSuffix(target, ".md")
|
||||
}
|
||||
|
||||
// codeSpans returns the byte ranges of fenced blocks and inline code spans.
|
||||
func codeSpans(content string) [][2]int {
|
||||
var spans [][2]int
|
||||
for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) {
|
||||
spans = append(spans, [2]int{m[0], m[1]})
|
||||
}
|
||||
for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) {
|
||||
spans = append(spans, [2]int{m[0], m[1]})
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// overlaps reports whether [start,end) intersects any span.
|
||||
func overlaps(start, end int, spans [][2]int) bool {
|
||||
for _, s := range spans {
|
||||
if start < s[1] && end > s[0] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package brain_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// seedCorpus writes the two entries every linkcheck case resolves against.
|
||||
func seedCorpus(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
seedNote(t, dir, "wiki/homelab/facts/real-entry.md",
|
||||
"---\nwing: homelab\nhall: facts\ntitle: 'Real entry'\n---\n\nbody.\n")
|
||||
seedNote(t, dir, "knowledge/legacy-entry.md",
|
||||
"---\ntitle: 'Legacy entry'\n---\n\nbody.\n")
|
||||
}
|
||||
|
||||
func TestStripUnresolvedLinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
stripped []string
|
||||
}{
|
||||
{
|
||||
name: "bare slug that resolves is kept",
|
||||
body: "See [[real-entry]] for the rule.",
|
||||
want: "See [[real-entry]] for the rule.",
|
||||
},
|
||||
{
|
||||
name: "path-style target that resolves is kept",
|
||||
body: "See [[homelab/facts/real-entry]].",
|
||||
want: "See [[homelab/facts/real-entry]].",
|
||||
},
|
||||
{
|
||||
name: "knowledge/ entry resolves by bare slug",
|
||||
body: "See [[legacy-entry]].",
|
||||
want: "See [[legacy-entry]].",
|
||||
},
|
||||
{
|
||||
name: "unresolvable target is unwrapped to plain text",
|
||||
body: "Related: [[secret-hygiene]].",
|
||||
want: "Related: secret-hygiene.",
|
||||
stripped: []string{"secret-hygiene"},
|
||||
},
|
||||
{
|
||||
name: "unresolvable aliased target keeps its display text",
|
||||
body: "Related: [[feedback_check_host_not_assume|check the host]].",
|
||||
want: "Related: check the host.",
|
||||
stripped: []string{"feedback_check_host_not_assume"},
|
||||
},
|
||||
{
|
||||
name: "link inside a fenced code block is left alone",
|
||||
body: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||
want: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||
},
|
||||
{
|
||||
name: "link inside an inline code span is left alone",
|
||||
body: "Write `[[wing/hall/slug]]` to link.",
|
||||
want: "Write `[[wing/hall/slug]]` to link.",
|
||||
},
|
||||
{
|
||||
name: "every unresolvable target is reported once, sorted",
|
||||
body: "[[zeta-gap]] and [[alpha-gap]] and [[zeta-gap]] again.",
|
||||
want: "zeta-gap and alpha-gap and zeta-gap again.",
|
||||
stripped: []string{"alpha-gap", "zeta-gap"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seedCorpus(t, dir)
|
||||
|
||||
got, stripped, err := brain.StripUnresolvedLinks(dir, tc.body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
assert.Equal(t, tc.stripped, stripped)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUnresolvedLinks_MissingBrainDirIsNotAGate(t *testing.T) {
|
||||
// A brain dir with no knowledge//wiki/ tree yet must not turn every link
|
||||
// into an unresolved one — an empty corpus is a bootstrap state, not
|
||||
// evidence that the author invented the target.
|
||||
got, stripped, err := brain.StripUnresolvedLinks(t.TempDir(), "See [[real-entry]].")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "See [[real-entry]].", got)
|
||||
assert.Nil(t, stripped)
|
||||
}
|
||||
@@ -42,6 +42,12 @@ func (s *Store) WithGraph(g graphsync.Store) *Store {
|
||||
|
||||
// Write creates a brain note and returns its read-after-write handle.
|
||||
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
n.Content = content
|
||||
|
||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||
Content: n.Content,
|
||||
Filename: n.Filename,
|
||||
@@ -57,13 +63,19 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error)
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
|
||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Unlinked: unlinked}, nil
|
||||
}
|
||||
|
||||
// Update supersedes an existing note in place. slug may be a bare slug
|
||||
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||
// contains a slash). It never creates — a missing target is an error.
|
||||
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||
if err != nil {
|
||||
return capture.Ref{}, err
|
||||
}
|
||||
n.Content = content
|
||||
|
||||
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||
if strings.Contains(slug, "/") {
|
||||
opts.Path = slug
|
||||
@@ -80,7 +92,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_update", relPath)
|
||||
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
|
||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true, Unlinked: unlinked}, nil
|
||||
}
|
||||
|
||||
// Get fetches a note by id/path — the read-after-write confirmation
|
||||
|
||||
@@ -83,3 +83,45 @@ func TestStoreGetRoundTripsHash(t *testing.T) {
|
||||
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||
assert.Contains(t, note.Body, "# Body")
|
||||
}
|
||||
|
||||
func TestStoreWriteUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
_, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ref, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "See [[target]] and [[secret-hygiene]].\n",
|
||||
Filename: "src", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"secret-hygiene"}, ref.Unlinked)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/src.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "[[target]]")
|
||||
assert.Contains(t, string(got), "and secret-hygiene.")
|
||||
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||
}
|
||||
|
||||
func TestStoreUpdateUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := brainstore.New(dir)
|
||||
_, err := s.Write(context.Background(), capture.Note{
|
||||
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||
Content: "Related: [[cad-babysit]].\n", Wing: "a", Hall: "facts", Reason: "r",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"cad-babysit"}, ref.Unlinked)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "Related: cad-babysit.")
|
||||
assert.NotContains(t, string(got), "[[cad-babysit]]")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ type Ref struct {
|
||||
Path string
|
||||
ContentHash string
|
||||
Superseded bool
|
||||
// Unlinked names the [[wikilink]] targets the store unwrapped to plain
|
||||
// text because they resolve to no entry. Reported rather than rejected:
|
||||
// see brain.StripUnresolvedLinks for why the write still lands.
|
||||
Unlinked []string
|
||||
}
|
||||
|
||||
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||
|
||||
@@ -38,9 +38,11 @@ func (f fakeTracker) CreateIssue(context.Context, string, string, string) (captu
|
||||
}
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
|
||||
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
@@ -183,6 +185,7 @@ type refusingAudit struct{}
|
||||
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||
return 0, errors.New("central audit sink unreachable")
|
||||
}
|
||||
|
||||
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// recordingHandler captures log records so a test can assert that a file was
|
||||
// never even attempted — the absence of a read error is the evidence.
|
||||
type recordingHandler struct {
|
||||
mu sync.Mutex
|
||||
records []string
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
|
||||
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var sb strings.Builder
|
||||
sb.WriteString(r.Message)
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
sb.WriteString(" " + a.Key + "=" + a.Value.String())
|
||||
return true
|
||||
})
|
||||
h.records = append(h.records, sb.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
func (h *recordingHandler) contains(substr string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, r := range h.records {
|
||||
if strings.Contains(r, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func session(t *testing.T, root, project, id string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, project)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
return writeSession(t, dir, id, []string{
|
||||
`{"type":"user","sessionId":"` + id + `","message":"a prompt"}`,
|
||||
`{"type":"assistant","sessionId":"` + id + `","message":{"content":[{"type":"text","text":"an answer"}]}}`,
|
||||
})
|
||||
}
|
||||
|
||||
// A named project is not ingested, and its neighbours still are.
|
||||
func TestSkipProjects_skipsTheNamedProjectOnly(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "skipped")
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"-home-mathias-dev-AGENTS-cobalt-dingo"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
|
||||
}
|
||||
|
||||
// Globs are necessary, not a convenience. A project directory is derived from
|
||||
// the session's WORKING DIRECTORY, so a git worktree produces an ephemeral
|
||||
// name like "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC...". An
|
||||
// exact-match list could never keep up with those.
|
||||
func TestSkipProjects_globMatchesEphemeralWorktreeNames(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev--claude-worktrees-bridge-cse-017xoCEcrc35", "worktree")
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"*claude-worktrees-bridge-cse*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
}
|
||||
|
||||
// The point of skipping at the directory walk: the file is never OPENED, so
|
||||
// its content never enters this process. Proven by making it unreadable — a
|
||||
// skipped file produces no read error because nothing tried to read it, while
|
||||
// the same file in a non-skipped project does.
|
||||
func TestSkipProjects_theFileIsNeverOpened(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||
require.NoError(t, os.Chmod(path, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||
|
||||
rec := &recordingHandler{}
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
Logger: slog.New(rec),
|
||||
SkipProjects: []string{"*cobalt-dingo*"},
|
||||
}))
|
||||
|
||||
assert.Empty(t, sink.batches)
|
||||
assert.False(t, rec.contains("file failed"),
|
||||
"a skipped file must not even be attempted")
|
||||
}
|
||||
|
||||
// The control test for the one above: without the skip, the unreadable file IS
|
||||
// attempted and fails. Otherwise the assertion above could pass for the wrong
|
||||
// reason.
|
||||
func TestSkipProjects_withoutTheSkipTheSameFileIsAttempted(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||
require.NoError(t, os.Chmod(path, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||
|
||||
rec := &recordingHandler{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: &memSink{},
|
||||
Logger: slog.New(rec),
|
||||
}))
|
||||
|
||||
assert.True(t, rec.contains("file failed"),
|
||||
"the unreadable file must fail when not skipped — otherwise the skip test proves nothing")
|
||||
}
|
||||
|
||||
// Patterns match the project directory NAME, not the whole path. A temp
|
||||
// directory or a home directory component must not accidentally match and
|
||||
// silently disable ingestion.
|
||||
func TestSkipProjects_matchesTheProjectNameNotTheFullPath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
// tmp is somewhere under /tmp; this must not match the project name.
|
||||
SkipProjects: []string{"*tmp*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1, "a path component outside the project name must not match")
|
||||
}
|
||||
|
||||
// An unparseable pattern must stop the watcher rather than be ignored. A
|
||||
// silently dropped skip rule means ingesting what was meant to be excluded,
|
||||
// which is the failure that cannot be noticed after the fact.
|
||||
func TestSkipProjects_invalidPatternRefusesToRun(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
err := TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"[unclosed"},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "[unclosed")
|
||||
assert.Empty(t, sink.batches, "nothing may be ingested while a skip rule is broken")
|
||||
}
|
||||
|
||||
func TestSkipProjects_emptyListChangesNothing(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: nil,
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
}
|
||||
|
||||
// The difference between SkipDir and filtering after the walk, made
|
||||
// observable: an unreadable skipped directory must not disturb anything.
|
||||
//
|
||||
// Without SkipDir, WalkDir reads the directory's entries, gets a permission
|
||||
// error, and that error aborts the ENTIRE walk — so one skipped project with
|
||||
// awkward permissions would silently stop ingesting every other project.
|
||||
// Returning SkipDir means its contents are never listed at all.
|
||||
//
|
||||
// Added after a mutation test showed the earlier assertions could not tell the
|
||||
// two implementations apart.
|
||||
func TestSkipProjects_anUnreadableSkippedDirDoesNotBreakTheWalk(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
blocked := filepath.Join(tmp, "-home-mathias-dev-AGENTS-cobalt-dingo")
|
||||
require.NoError(t, os.MkdirAll(blocked, 0o755))
|
||||
_ = writeSession(t, blocked, "secret", []string{
|
||||
`{"type":"user","sessionId":"secret","message":"should never be read"}`,
|
||||
})
|
||||
require.NoError(t, os.Chmod(blocked, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) })
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"*cobalt-dingo*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1, "an unreadable skipped directory must not abort the walk")
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
}
|
||||
@@ -45,6 +45,25 @@ type Config struct {
|
||||
Sink Sink
|
||||
Cursors *CursorStore
|
||||
Logger *slog.Logger
|
||||
|
||||
// SkipProjects excludes whole project directories from ingestion, as
|
||||
// filepath.Match globs tested against the project directory NAME (the
|
||||
// basename, e.g. "-home-mathias-dev"), not the full path.
|
||||
//
|
||||
// Globs rather than exact names because a project directory is derived
|
||||
// from the session's working directory, so a git worktree yields an
|
||||
// ephemeral name like
|
||||
// "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC..." that no
|
||||
// exact-match list could keep up with.
|
||||
//
|
||||
// Matching directories are skipped during the directory WALK, so their
|
||||
// files are never opened and their content never enters this process —
|
||||
// which is a stronger property than filtering it out afterwards.
|
||||
//
|
||||
// An unparseable pattern stops the watcher. A silently dropped skip rule
|
||||
// means ingesting what was meant to be excluded, and that is not
|
||||
// noticeable after the fact.
|
||||
SkipProjects []string
|
||||
}
|
||||
|
||||
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
||||
@@ -68,6 +87,9 @@ func Watch(ctx context.Context, cfg Config) error {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Logger.Info("claudewatcher: started",
|
||||
"sessions_dir", cfg.SessionsDir,
|
||||
"host", cfg.Host,
|
||||
@@ -91,7 +113,7 @@ func Watch(ctx context.Context, cfg Config) error {
|
||||
// runTick is one polling pass. Exposed (lowercase) for tests via
|
||||
// TickOnce.
|
||||
func runTick(ctx context.Context, cfg Config) {
|
||||
files, err := listSessionFiles(cfg.SessionsDir)
|
||||
files, err := listSessionFiles(cfg.SessionsDir, cfg.SkipProjects, cfg.Logger)
|
||||
if err != nil {
|
||||
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
||||
return
|
||||
@@ -119,17 +141,52 @@ func TickOnce(ctx context.Context, cfg Config) error {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||
return err
|
||||
}
|
||||
runTick(ctx, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func listSessionFiles(root string) ([]string, error) {
|
||||
// validateSkipPatterns rejects a pattern filepath.Match cannot parse.
|
||||
//
|
||||
// Checked up front rather than per file: a bad pattern that only failed at
|
||||
// match time would be reported once per session file per tick, and — worse —
|
||||
// filepath.Match returns an error alongside a false match, so the directory
|
||||
// would be ingested while the log complained.
|
||||
func validateSkipPatterns(patterns []string) error {
|
||||
for _, p := range patterns {
|
||||
if _, err := filepath.Match(p, "probe"); err != nil {
|
||||
return fmt.Errorf("invalid skip-project pattern %q: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipProject reports whether a project directory name matches any pattern.
|
||||
func skipProject(name string, patterns []string) bool {
|
||||
for _, p := range patterns {
|
||||
// The error case is already excluded by validateSkipPatterns.
|
||||
if ok, _ := filepath.Match(p, name); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func listSessionFiles(root string, skip []string, log *slog.Logger) ([]string, error) {
|
||||
var out []string
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
// SkipDir, so nothing inside is even listed. The files are never
|
||||
// opened and their content never reaches this process.
|
||||
if path != root && skipProject(d.Name(), skip) {
|
||||
log.Info("claudewatcher: project skipped by configuration", "project", d.Name())
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".jsonl") {
|
||||
|
||||
@@ -5,14 +5,18 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Router calls Primary first; on any error falls back to Fallback.
|
||||
// Fallback may be nil, in which case primary errors are returned directly.
|
||||
// Router calls Primary first; on any error falls back to Fallback, then to
|
||||
// Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
|
||||
// the chain returns the errors accumulated so far.
|
||||
type Router struct {
|
||||
Primary *Client
|
||||
Fallback *Client
|
||||
Tertiary *Client
|
||||
}
|
||||
|
||||
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
||||
// Complete implements pipeline.CompleteFunc, routing through Primary, then
|
||||
// Fallback, then Tertiary — trying each configured tier in order and
|
||||
// returning the first success.
|
||||
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
out, err := r.Primary.Complete(ctx, system, user)
|
||||
if err == nil {
|
||||
@@ -22,8 +26,15 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
|
||||
return "", fmt.Errorf("primary llm: %w", err)
|
||||
}
|
||||
out, err2 := r.Fallback.Complete(ctx, system, user)
|
||||
if err2 != nil {
|
||||
if err2 == nil {
|
||||
return out, nil
|
||||
}
|
||||
if r.Tertiary == nil {
|
||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
||||
}
|
||||
out, err3 := r.Tertiary.Complete(ctx, system, user)
|
||||
if err3 != nil {
|
||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v; tertiary llm: %v", err, err2, err3)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -69,3 +69,78 @@ func TestRouter_NilFallback(t *testing.T) {
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer fail.Close()
|
||||
tertiary := mockServer(t, "from-tertiary")
|
||||
defer tertiary.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-tertiary", out)
|
||||
}
|
||||
|
||||
func TestRouter_TertiaryNotCalledWhenFallbackSucceeds(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer fail.Close()
|
||||
fallback := mockServer(t, "from-fallback")
|
||||
defer fallback.Close()
|
||||
tertiary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("tertiary must not be called when fallback succeeds")
|
||||
}))
|
||||
defer tertiary.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-fallback", out)
|
||||
}
|
||||
|
||||
func TestRouter_AllThreeFail(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
Tertiary: New(fail.URL, "", "m", time.Second),
|
||||
}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "primary llm")
|
||||
assert.Contains(t, err.Error(), "fallback llm")
|
||||
assert.Contains(t, err.Error(), "tertiary llm")
|
||||
}
|
||||
|
||||
func TestRouter_NilTertiary(t *testing.T) {
|
||||
// Existing 2-tier deployments (no BRAIN_LLM_TERTIARY_URL) must keep working
|
||||
// exactly as before — Tertiary is opt-in, not required.
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "tertiary")
|
||||
}
|
||||
|
||||
@@ -233,6 +233,15 @@ type brainWriteArgs struct {
|
||||
Hall string `json:"hall,omitempty"`
|
||||
}
|
||||
|
||||
// unlinkedNote explains the `unlinked` field to the caller that caused it.
|
||||
// The write landed; what is gone is the [[...]] around a target that names no
|
||||
// brain entry. Said here rather than only in a doc comment because the caller
|
||||
// is a model that will otherwise repeat it on the next write.
|
||||
const unlinkedNote = "these [[wikilink]] targets resolve to no brain entry, " +
|
||||
"so they were unwrapped to plain text. Link only entries that exist " +
|
||||
"(a filename under knowledge/ or wiki/); write the target entry first if " +
|
||||
"you want the link, and keep skills, repos and agent-local memory files as prose."
|
||||
|
||||
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainWriteArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
@@ -253,7 +262,12 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
||||
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}
|
||||
if len(ref.Unlinked) > 0 {
|
||||
out["unlinked"] = ref.Unlinked
|
||||
out["unlinked_note"] = unlinkedNote
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
type brainUpdateArgs struct {
|
||||
@@ -297,9 +311,14 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
out := map[string]any{
|
||||
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||
})
|
||||
}
|
||||
if len(ref.Unlinked) > 0 {
|
||||
out["unlinked"] = ref.Unlinked
|
||||
out["unlinked_note"] = unlinkedNote
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
type brainGetArgs struct {
|
||||
|
||||
@@ -134,9 +134,11 @@ type stubTracker struct{}
|
||||
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ type capFakeTracker struct{}
|
||||
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
|
||||
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
|
||||
calls++
|
||||
return "ready", nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
@@ -41,7 +40,6 @@ func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
|
||||
}
|
||||
return "ready", nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
// reachable database.
|
||||
//
|
||||
// To run:
|
||||
//
|
||||
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
||||
// go test ./internal/vectorstore/... -run Integration
|
||||
func dsn(t *testing.T) string {
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ skills:
|
||||
func writeModels(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "models.yaml")
|
||||
require.NoError(t, os.WriteFile(f, []byte(content), 0644))
|
||||
require.NoError(t, os.WriteFile(f, []byte(content), 0o644))
|
||||
return f
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s stubSkill) Tools() []registry.ToolDef {
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
}}
|
||||
}
|
||||
|
||||
func (s stubSkill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
+62
-18
@@ -5,15 +5,29 @@
|
||||
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
||||
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
||||
#
|
||||
# Usage: ./scripts/context-sync.sh [adapter...]
|
||||
# Cursor and Aider adapters removed 2026-09-01 — neither is a supported harness
|
||||
# any more. Supported: Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush,
|
||||
# Pi. All read CLAUDE.md or AGENTS.md; system-prompt.txt remains for Open WebUI,
|
||||
# Mods and raw API use.
|
||||
#
|
||||
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
|
||||
# Task: task context:sync
|
||||
#
|
||||
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Parse --force flag and collect adapter names separately
|
||||
FORCE=false
|
||||
ADAPTERS=()
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--force) FORCE=true ;;
|
||||
*) ADAPTERS+=("$_arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROJECT_FILE=".context/PROJECT.md"
|
||||
SKILLS_DIR=".skills"
|
||||
|
||||
# Walk up to find root .context/AGENT.md
|
||||
find_root_context() {
|
||||
@@ -36,19 +50,28 @@ if [ ! -f "$PROJECT_FILE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pre-flight: reject unfilled {{...}} placeholders unless --force
|
||||
if [ "$FORCE" = false ]; then
|
||||
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
|
||||
if [ -n "$_placeholders" ]; then
|
||||
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
|
||||
while IFS= read -r _match; do
|
||||
_lineno="${_match%%:*}"
|
||||
_content="${_match#*:}"
|
||||
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||
done <<< "$_placeholders"
|
||||
echo "" >&2
|
||||
echo "Fill these placeholders, then re-run: task context:sync" >&2
|
||||
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
echo " Root context: $ROOT_CONTEXT"
|
||||
else
|
||||
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
||||
# adapters (AGENTS.md, system-prompt.txt) require the root context to
|
||||
# regenerate correctly, so we skip them entirely and only regenerate CLAUDE.md
|
||||
# (which is project-only and inherits root via tree walk in Claude Code itself).
|
||||
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
||||
echo "Syncing project context from $PROJECT_FILE..."
|
||||
cat "$PROJECT_FILE" > CLAUDE.md
|
||||
echo " → CLAUDE.md (project-only)"
|
||||
echo "Done."
|
||||
exit 0
|
||||
echo " No root AGENT.md found (project context only)"
|
||||
fi
|
||||
|
||||
# Emit root context + separator
|
||||
@@ -95,6 +118,7 @@ generate_system_prompt() {
|
||||
|
||||
# ── MCP config ───────────────────────────────────────────────
|
||||
generate_mcp() {
|
||||
# Ensure baseline file exists with project-specific knowledge server
|
||||
if [ ! -f .context/mcp.json ]; then
|
||||
cat > .context/mcp.json << 'JSON'
|
||||
{
|
||||
@@ -106,27 +130,47 @@ generate_mcp() {
|
||||
}
|
||||
}
|
||||
JSON
|
||||
echo " → .context/mcp.json (new)"
|
||||
else
|
||||
echo " → .context/mcp.json (exists, skipped)"
|
||||
fi
|
||||
|
||||
# Merge root mcp-servers.json if found alongside root AGENT.md
|
||||
local root_mcp=""
|
||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
local candidate
|
||||
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
|
||||
[ -f "$candidate" ] && root_mcp="$candidate"
|
||||
fi
|
||||
|
||||
if [ -z "$root_mcp" ]; then
|
||||
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Root servers take precedence over project entries on key conflict
|
||||
local root_servers count updated
|
||||
root_servers=$(jq '.servers' "$root_mcp")
|
||||
count=$(printf '%s' "$root_servers" | jq 'keys | length')
|
||||
updated=$(jq --argjson root "$root_servers" \
|
||||
'.mcpServers = (.mcpServers + $root)' \
|
||||
.context/mcp.json)
|
||||
printf '%s\n' "$updated" > .context/mcp.json
|
||||
echo " → .context/mcp.json (merged $count root servers)"
|
||||
}
|
||||
|
||||
echo "Syncing project context from $PROJECT_FILE..."
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
if [ ${#ADAPTERS[@]} -eq 0 ]; then
|
||||
generate_claude
|
||||
generate_agents
|
||||
generate_system_prompt
|
||||
generate_mcp
|
||||
else
|
||||
for adapter in "$@"; do
|
||||
for adapter in "${ADAPTERS[@]}"; do
|
||||
case "$adapter" in
|
||||
claude) generate_claude ;;
|
||||
agents) generate_agents ;;
|
||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||
mcp) generate_mcp ;;
|
||||
*) echo "Unknown adapter: $adapter" ;;
|
||||
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user