Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db00644cab |
+19
-75
@@ -53,30 +53,14 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
the tiered merge model in `assessor-loop`
|
session. Do not leave agent branches open between sessions.
|
||||||
`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.
|
|
||||||
|
|
||||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
collisions between parallel sessions: two were not branching problems at all — a
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
generated artifact written to the same path by both (branching turns that into a
|
the reason in PROJECT.md.
|
||||||
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
|
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:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -94,18 +78,6 @@ 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
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
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
|
## CAD sprint patterns
|
||||||
|
|
||||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
@@ -176,10 +148,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s 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
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -275,11 +247,10 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
This context is designed to work with any AI coding tool:
|
||||||
re-add adapters for them:
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -291,29 +262,15 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.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:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
|
||||||
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
|
||||||
up** from cwd and takes the first `AGENTS.md` it finds
|
|
||||||
(user-level `~/.vibe/AGENTS.md`)
|
|
||||||
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
|
||||||
any harness, it is for pasting
|
|
||||||
|
|
||||||
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
|
||||||
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
|
||||||
inside that repo, so this works — but by construction, not by luck.
|
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -324,20 +281,7 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**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:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Presence of this file marks this repo as opt-in for headless dispatch.
|
|
||||||
# See dispatch#3.
|
|
||||||
@@ -45,12 +45,6 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||||
IMAGE_TAG="${{ github.sha }}"
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
# infra#311: github.sha is never empty in practice, but the manifest
|
|
||||||
# patch below is the same shape that took Flux down estate-wide on
|
|
||||||
# 2026-08-25 -- an empty tag yields `image: <repo>:`, which is invalid
|
|
||||||
# YAML and fails the ENTIRE Kustomization, not just this app. The
|
|
||||||
# assert costs nothing and removes the class.
|
|
||||||
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
chmod 600 ~/.ssh/infra_deploy_key
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
+5
-13
@@ -56,16 +56,8 @@ paths = [
|
|||||||
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||||
]
|
]
|
||||||
|
|
||||||
# infra#344: the allowlist that used to sit here is gone, and so is the reason
|
[[allowlists]]
|
||||||
# for it. It suppressed docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
|
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)"
|
||||||
# on the stated grounds that "extracted Secret == 'REDACTED' exactly ... the
|
condition = "AND"
|
||||||
# placeholder text itself, not a real credential" (verified 2026-08-04). That was
|
paths = ['''docs/superpowers/plans/2026-04-22-phase4-attempt-wiring\.md$''']
|
||||||
# not what the file contained: line 1054 carried a real 40-char hex Gitea PAT in
|
regexes = ['''REDACTED''']
|
||||||
# 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.
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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,30 +48,14 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
actively working on the same repo simultaneously, create a short-lived branch
|
||||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
(`agent/<description>`), finish the task, and merge to main within the same
|
||||||
the tiered merge model in `assessor-loop`
|
session. Do not leave agent branches open between sessions.
|
||||||
`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.
|
|
||||||
|
|
||||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
**Exception — external contributor or client four-eyes requirement:** Use
|
||||||
collisions between parallel sessions: two were not branching problems at all — a
|
PR flow only when a human reviewer outside the project is required. Document
|
||||||
generated artifact written to the same path by both (branching turns that into a
|
the reason in PROJECT.md.
|
||||||
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
|
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:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -89,18 +73,6 @@ 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
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
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
|
## CAD sprint patterns
|
||||||
|
|
||||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
@@ -171,10 +143,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||||
|
|
||||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||||
- **Orchestration**: k3s 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
|
- **Orchestration**: k3s cluster across all three machines
|
||||||
- **Networking**: Tailscale mesh
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -270,11 +242,10 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
This context is designed to work with any AI coding tool:
|
||||||
re-add adapters for them:
|
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
- Any tool that accepts a system prompt or reads a markdown context file
|
||||||
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
|
||||||
|
|
||||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -286,29 +257,15 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.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:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||||
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
|
||||||
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
|
||||||
up** from cwd and takes the first `AGENTS.md` it finds
|
|
||||||
(user-level `~/.vibe/AGENTS.md`)
|
|
||||||
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
|
||||||
any harness, it is for pasting
|
|
||||||
|
|
||||||
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
|
||||||
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
|
||||||
inside that repo, so this works — but by construction, not by luck.
|
|
||||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -319,20 +276,7 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**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:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
|
|||||||
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||||
into a searchable brain.
|
into a searchable brain.
|
||||||
|
|
||||||
**This repo is also where `brain-mcp` lives** — the MCP server at
|
|
||||||
`brain-mcp.d-ma.be` that `brain_query`, `brain_write`, `brain_read`, `brain_edit`
|
|
||||||
and `capture` are served from. It is not a separate deployable; see
|
|
||||||
[Where brain-mcp lives](#where-brain-mcp-lives).
|
|
||||||
|
|
||||||
## Hypothesis
|
## Hypothesis
|
||||||
|
|
||||||
> We believe routing skill tasks through local models, backed by brain context,
|
> We believe routing skill tasks through local models, backed by brain context,
|
||||||
@@ -25,7 +20,7 @@ usage, the fast-model routing path should be reconsidered.
|
|||||||
|
|
||||||
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
||||||
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
||||||
[agentsquad](https://git.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
[agentsquad](http://gitea.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
||||||
orchestration model. Skills (mathias/skills) are shared between both.
|
orchestration model. Skills (mathias/skills) are shared between both.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
@@ -49,33 +44,6 @@ brain/
|
|||||||
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Where brain-mcp lives
|
|
||||||
|
|
||||||
`brain-mcp` is served from **`ingestion/internal/mcp`** in this repo, at
|
|
||||||
**`brain-mcp.d-ma.be`**. It is a component *inside* the `ingestion` service, not
|
|
||||||
a standalone deployable — one pod in the `supervisor` namespace serves the MCP
|
|
||||||
endpoint, the REST API, and the claudewatcher together.
|
|
||||||
|
|
||||||
| You are looking for | It is here |
|
|
||||||
|---|---|
|
|
||||||
| The MCP tool surface (`brain_query`, `brain_write`, `brain_read`, `brain_edit`, `capture`, …) | `ingestion/internal/mcp` |
|
|
||||||
| The HTTP/REST API (`/query`, `/write`, `/ingest`) | `ingestion/internal/api` |
|
|
||||||
| Session ingestion from Claude Code transcripts | `ingestion/internal/claudewatcher` |
|
|
||||||
| The brain *content* (notes, wings, halls) | [`mathias/brain`](https://git.d-ma.be/mathias/brain) — a separate repo |
|
|
||||||
| Deployment manifests | `config/supervisor` |
|
|
||||||
|
|
||||||
### Why it is not its own repo
|
|
||||||
|
|
||||||
Asked and decided in [#91](https://git.d-ma.be/mathias/hyperguild/issues/91)
|
|
||||||
(part 2). `internal/mcp` imports 17 of ingestion's other internal packages and
|
|
||||||
has exactly one importer — it is the MCP facade over the service's own
|
|
||||||
internals, so "move it out" resolves to renaming `ingestion`, publishing 17
|
|
||||||
packages as a shared library, or maintaining a shim. The discoverability problem
|
|
||||||
that prompted the question is answered by naming it here instead.
|
|
||||||
|
|
||||||
Full reasoning, including the reversal trigger: brain
|
|
||||||
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
|
||||||
|
|
||||||
## Phase 1 tools (available now)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
@@ -90,7 +58,7 @@ Full reasoning, including the reversal trigger: brain
|
|||||||
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||||
|
|
||||||
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
||||||
> They are now SKILL.md files in [mathias/skills](https://git.d-ma.be/mathias/skills).
|
> They are now SKILL.md files in [mathias/skills](http://gitea.d-ma.be/mathias/skills).
|
||||||
|
|
||||||
## Connect a project
|
## Connect a project
|
||||||
|
|
||||||
@@ -179,7 +147,7 @@ The routing pod selects models per skill call based on historical pass rate:
|
|||||||
|
|
||||||
## Open issues
|
## Open issues
|
||||||
|
|
||||||
See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
|
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||||
|
|
||||||
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||||
- **#24** — reduce context burn from skill listing
|
- **#24** — reduce context burn from skill listing
|
||||||
|
|||||||
+10
-29
@@ -86,37 +86,18 @@ tasks:
|
|||||||
check:
|
check:
|
||||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
- task: context:sync
|
||||||
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
|
||||||
# the tree for the canonical .context/AGENT.md, which lives in local-dev
|
|
||||||
# and does not exist on a CI runner. Without this guard, the script
|
|
||||||
# silently falls back to "project context only" and regenerates AGENTS.md
|
|
||||||
# and .context/system-prompt.txt shorter than their committed, correct
|
|
||||||
# versions -- so the drift assertion fails every CI run for a purely
|
|
||||||
# environmental reason, not real drift. Verified, not assumed: this is
|
|
||||||
# exactly what was happening here (AGENTS.md 430 lines committed vs. an
|
|
||||||
# 82-line regeneration on the runner).
|
|
||||||
#
|
|
||||||
# The cost is real and worth stating: drift is therefore only ever caught
|
|
||||||
# on a developer machine, or by infra's estate-wide context-sync-drift
|
|
||||||
# CronJob (ADR-0019), which does have canonical access and is the actual
|
|
||||||
# enforcement mechanism now.
|
|
||||||
- cmd: |
|
- cmd: |
|
||||||
if [ -n "${CI:-}" ]; then
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||||
echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
|
if [ -n "$drift" ]; then
|
||||||
else
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
bash scripts/context-sync.sh
|
echo "$drift" >&2
|
||||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
echo "" >&2
|
||||||
if [ -n "$drift" ]; then
|
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
|
||||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
||||||
echo "$drift" >&2
|
exit 1
|
||||||
echo "" >&2
|
|
||||||
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
|
|
||||||
echo " git commit -m 'chore: re-sync context adapters'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✓ context: canonical and adapters are in sync"
|
|
||||||
fi
|
fi
|
||||||
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
|
|||||||
@@ -1051,7 +1051,7 @@ git push
|
|||||||
```bash
|
```bash
|
||||||
# Poll until complete (replace RUN_ID with the new run):
|
# Poll until complete (replace RUN_ID with the new run):
|
||||||
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
||||||
-H "Authorization: token <REDACTED-dead-gitea-pat-infra#344>" \
|
-H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \
|
||||||
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -144,16 +144,6 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildNotifier returns the ntfy notifier, or a nil interface when
|
|
||||||
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
|
||||||
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
|
||||||
func buildNotifier() audit.Notifier {
|
|
||||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
||||||
// set it builds the classification-aware DegradingSink (loki central +
|
// set it builds the classification-aware DegradingSink (loki central +
|
||||||
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
||||||
@@ -171,7 +161,12 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
|
|||||||
logger.Error("capture audit buffer init", "err", err)
|
logger.Error("capture audit buffer init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
notifier := buildNotifier()
|
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
|
||||||
|
// so DegradingSink/Reconcile skip it cleanly.
|
||||||
|
var notifier audit.Notifier
|
||||||
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
|
notifier = n
|
||||||
|
}
|
||||||
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||||
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||||
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||||
@@ -236,11 +231,6 @@ func main() {
|
|||||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||||
}
|
}
|
||||||
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
|
|
||||||
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
|
|
||||||
tertiaryKey := os.Getenv("GEMINI_API_KEY")
|
|
||||||
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
|
|
||||||
}
|
|
||||||
answerComplete = router.Complete
|
answerComplete = router.Complete
|
||||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||||
}
|
}
|
||||||
@@ -500,13 +490,7 @@ func main() {
|
|||||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||||
// WithCapture mutates the same instance, so the tool appears live.
|
// WithCapture mutates the same instance, so the tool appears live.
|
||||||
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
// hyperguild#97: a governance-file rewrite gets the same trail a
|
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
|
||||||
// capture already gets, plus an out-of-band alert. Wired here because
|
|
||||||
// brain_edit_root needs the capture plumbing anyway — that is what
|
|
||||||
// re-derives the static principal its gate checks.
|
|
||||||
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
|
|
||||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
|
|
||||||
"governance_observer", true)
|
|
||||||
} else {
|
} else {
|
||||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -153,24 +152,15 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
existingFields, body := splitFrontmatter(opts.Content)
|
existingFields, body := splitFrontmatter(opts.Content)
|
||||||
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||||
for _, f := range existingFields {
|
for _, f := range existingFields {
|
||||||
existingByKey[f.key] = f
|
existingByKey[f.key] = f
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the claim-property envelope BEFORE creating the directory, so a
|
|
||||||
// rejected write leaves nothing behind — not even an empty hall dir
|
|
||||||
// (brain#21). Fail closed: a value outside the enum is worse than an absent
|
|
||||||
// one, because a lint skips what it cannot parse and the note then reads as
|
|
||||||
// classified while nothing acts on it.
|
|
||||||
if err := claim.ValidateEnvelope(scalarValues(existingFields)); err != nil {
|
|
||||||
return "", fmt.Errorf("claim envelope: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
||||||
return "", fmt.Errorf("create hall dir: %w", err)
|
|
||||||
}
|
|
||||||
emitted := make(map[string]bool, 6)
|
emitted := make(map[string]bool, 6)
|
||||||
|
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
@@ -228,25 +218,6 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
return filepath.ToSlash(rel), nil
|
return filepath.ToSlash(rel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scalarValues flattens frontmatter fields to key -> scalar value for
|
|
||||||
// validation. Multi-line values (lists, nested blocks) yield the text after
|
|
||||||
// the colon on the first line, which is empty for a list — correct here,
|
|
||||||
// since every claim-property field is a scalar enum.
|
|
||||||
func scalarValues(fields []frontmatterField) map[string]string {
|
|
||||||
out := make(map[string]string, len(fields))
|
|
||||||
for _, f := range fields {
|
|
||||||
if len(f.lines) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, value, found := strings.Cut(f.lines[0], ":")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[f.key] = strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// frontmatterField is one top-level YAML key from a frontmatter block,
|
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||||
// along with its raw line and any indented continuation lines (e.g. a
|
// along with its raw line and any indented continuation lines (e.g. a
|
||||||
// bulleted list value spanning multiple lines).
|
// bulleted list value spanning multiple lines).
|
||||||
|
|||||||
@@ -440,60 +440,3 @@ func TestIngestPath_Directory(t *testing.T) {
|
|||||||
require.True(t, ok, "pages must be an array")
|
require.True(t, ok, "pages must be an array")
|
||||||
assert.NotEmpty(t, pagesSlice)
|
assert.NotEmpty(t, pagesSlice)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- brain#21: claim-property envelope on the write path -------------------
|
|
||||||
|
|
||||||
func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) {
|
|
||||||
// writeHallNote already passes unknown keys through; this pins that
|
|
||||||
// behaviour for the envelope specifically, so a future refactor of the
|
|
||||||
// merge loop cannot silently drop it.
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
|
||||||
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n",
|
|
||||||
Wing: "homelab",
|
|
||||||
Hall: "decisions",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(got), "half_life: durable")
|
|
||||||
assert.Contains(t, string(got), "applies: universal")
|
|
||||||
assert.Contains(t, string(got), "claim_provenance: asserted")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) {
|
|
||||||
// Fail closed: a value outside the enum must not land. A lint skips what
|
|
||||||
// it cannot parse, so an unvalidated write produces a note that reads as
|
|
||||||
// classified while nothing acts on it.
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
|
||||||
Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n",
|
|
||||||
Filename: "bad-envelope",
|
|
||||||
Wing: "homelab",
|
|
||||||
Hall: "facts",
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "half_life")
|
|
||||||
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md"))
|
|
||||||
assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
|
||||||
Content: "---\napplies: homelab\n---\n\nBody.\n",
|
|
||||||
Filename: "no-provenance",
|
|
||||||
Wing: "homelab",
|
|
||||||
Hall: "facts",
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "claim_provenance")
|
|
||||||
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
// Package claim validates the claim-property frontmatter that says when and
|
|
||||||
// where a note's claim holds, and who decided that (brain#21).
|
|
||||||
//
|
|
||||||
// Two families were designed together and land separately. This is the
|
|
||||||
// applicability envelope:
|
|
||||||
//
|
|
||||||
// half_life: volatile | seasonal | durable — how fast it decays
|
|
||||||
// applies: local | homelab | universal — how widely it holds
|
|
||||||
// claim_provenance: asserted | inferred — who set the above
|
|
||||||
//
|
|
||||||
// The strength family (confidence, contested) follows once the ingest-time
|
|
||||||
// contradiction detector exists to populate it.
|
|
||||||
//
|
|
||||||
// Why the envelope is validated rather than merely accepted: a value outside
|
|
||||||
// the enum is worse than an absent one. A lint skips what it cannot parse, so
|
|
||||||
// the note reads as classified while nothing acts on it — the field becomes
|
|
||||||
// decoration that suppresses the very review it was added to trigger.
|
|
||||||
//
|
|
||||||
// Why `applies` and not `scope`: `scope:` has been in use since brain#5 for
|
|
||||||
// glossary terms (`homelab-core`, `project:<repo>`) — a disjoint value space
|
|
||||||
// on the same key. The shipped schema keeps the name; this one takes another.
|
|
||||||
package claim
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Field names, exported so writers and lints agree on the spelling.
|
|
||||||
const (
|
|
||||||
FieldHalfLife = "half_life"
|
|
||||||
FieldApplies = "applies"
|
|
||||||
FieldProvenance = "claim_provenance"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
halfLives = []string{"volatile", "seasonal", "durable"}
|
|
||||||
appliesTo = []string{"local", "homelab", "universal"}
|
|
||||||
provenances = []string{"asserted", "inferred"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValidateEnvelope checks the envelope fields of one note's frontmatter.
|
|
||||||
//
|
|
||||||
// Absent fields are valid: the envelope is opt-in for new writes, and a
|
|
||||||
// corpus-wide backfill is deliberately out of scope (brain#21) — inferring
|
|
||||||
// 1113 notes' properties in bulk has the same failure mode as the extraction
|
|
||||||
// pass that produced 28 wrong wikilinks out of 67.
|
|
||||||
func ValidateEnvelope(fm map[string]string) error {
|
|
||||||
halfLife, hasHalfLife := present(fm, FieldHalfLife)
|
|
||||||
applies, hasApplies := present(fm, FieldApplies)
|
|
||||||
prov, hasProv := present(fm, FieldProvenance)
|
|
||||||
|
|
||||||
if hasHalfLife {
|
|
||||||
if err := oneOf(FieldHalfLife, halfLife, halfLives); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasApplies {
|
|
||||||
if err := oneOf(FieldApplies, applies, appliesTo); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasProv {
|
|
||||||
if err := oneOf(FieldProvenance, prov, provenances); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The field and its provenance travel together or not at all. A value
|
|
||||||
// whose author is unknown cannot be trusted differently from one Mathias
|
|
||||||
// asserted, which is how an inferred guess hardens into accepted fact.
|
|
||||||
switch {
|
|
||||||
case (hasHalfLife || hasApplies) && !hasProv:
|
|
||||||
return fmt.Errorf("%s is required when %s or %s is set",
|
|
||||||
FieldProvenance, FieldHalfLife, FieldApplies)
|
|
||||||
case hasProv && !hasHalfLife && !hasApplies:
|
|
||||||
return fmt.Errorf("%s is set but neither %s nor %s is",
|
|
||||||
FieldProvenance, FieldHalfLife, FieldApplies)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// present reports a trimmed field value and whether it was set to anything
|
|
||||||
// non-empty. A key written with an empty value is treated as absent rather
|
|
||||||
// than as an error: that is a caller emitting a placeholder, not asserting.
|
|
||||||
func present(fm map[string]string, key string) (string, bool) {
|
|
||||||
v := strings.TrimSpace(fm[key])
|
|
||||||
return v, v != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func oneOf(field, value string, allowed []string) error {
|
|
||||||
for _, a := range allowed {
|
|
||||||
if a == value {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s: %q is not one of %s", field, value, strings.Join(allowed, ", "))
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// Package claim_test covers the claim-property envelope (brain#21).
|
|
||||||
package claim_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateEnvelope(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
fm map[string]string
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "absent is fine — the fields are opt-in on new writes",
|
|
||||||
fm: map[string]string{"wing": "homelab", "hall": "facts"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "complete envelope with provenance",
|
|
||||||
fm: map[string]string{
|
|
||||||
"half_life": "durable", "applies": "universal", "claim_provenance": "asserted",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inferred provenance is equally valid — it just must be stated",
|
|
||||||
fm: map[string]string{
|
|
||||||
"half_life": "volatile", "applies": "local", "claim_provenance": "inferred",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A value nobody can act on is worse than no value: a lint
|
|
||||||
// silently skips it, so the note looks classified and isn't.
|
|
||||||
name: "unknown half_life value",
|
|
||||||
fm: map[string]string{"half_life": "forever", "claim_provenance": "asserted"},
|
|
||||||
wantErr: `half_life: "forever" is not one of volatile, seasonal, durable`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown applies value",
|
|
||||||
fm: map[string]string{"applies": "galaxy", "claim_provenance": "asserted"},
|
|
||||||
wantErr: `applies: "galaxy" is not one of local, homelab, universal`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// brain#21: a value a model inferred must never be
|
|
||||||
// indistinguishable from one Mathias asserted. Writing the field
|
|
||||||
// without saying who set it is exactly that laundering.
|
|
||||||
name: "envelope field without provenance",
|
|
||||||
fm: map[string]string{"half_life": "durable"},
|
|
||||||
wantErr: "claim_provenance is required when half_life or applies is set",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "applies without provenance",
|
|
||||||
fm: map[string]string{"applies": "homelab"},
|
|
||||||
wantErr: "claim_provenance is required when half_life or applies is set",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown provenance value",
|
|
||||||
fm: map[string]string{"half_life": "durable", "claim_provenance": "probably"},
|
|
||||||
wantErr: `claim_provenance: "probably" is not one of asserted, inferred`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Provenance alone says who set fields that aren't there.
|
|
||||||
name: "provenance without any envelope field",
|
|
||||||
fm: map[string]string{"claim_provenance": "asserted"},
|
|
||||||
wantErr: "claim_provenance is set but neither half_life nor applies is",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The glossary wing has used scope: homelab-core since #5. The
|
|
||||||
// envelope deliberately does NOT claim that key (brain#21).
|
|
||||||
name: "glossary scope is untouched",
|
|
||||||
fm: map[string]string{"scope": "homelab-core", "term": "koala"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := claim.ValidateEnvelope(tt.fm)
|
|
||||||
if tt.wantErr == "" {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), tt.wantErr)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,18 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router calls Primary first; on any error falls back to Fallback, then to
|
// Router calls Primary first; on any error falls back to Fallback.
|
||||||
// Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
|
// Fallback may be nil, in which case primary errors are returned directly.
|
||||||
// the chain returns the errors accumulated so far.
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
Primary *Client
|
Primary *Client
|
||||||
Fallback *Client
|
Fallback *Client
|
||||||
Tertiary *Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete implements pipeline.CompleteFunc, routing through Primary, then
|
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
||||||
// 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) {
|
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||||
out, err := r.Primary.Complete(ctx, system, user)
|
out, err := r.Primary.Complete(ctx, system, user)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -26,15 +22,8 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
|
|||||||
return "", fmt.Errorf("primary llm: %w", err)
|
return "", fmt.Errorf("primary llm: %w", err)
|
||||||
}
|
}
|
||||||
out, err2 := r.Fallback.Complete(ctx, system, user)
|
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)
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,78 +69,3 @@ func TestRouter_NilFallback(t *testing.T) {
|
|||||||
_, err := r.Complete(context.Background(), "sys", "user")
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
assert.Error(t, err)
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func TestBrainUpdateReembedsNewContent(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
callResult(t, toolCall(t, srv, "brain_write", map[string]any{
|
||||||
"content": "# Note\n\nthe OLD distinctive payload\n",
|
"content": "# Note\n\nthe OLD distinctive payload\n",
|
||||||
"filename": "n", "wing": "a", "hall": "facts",
|
"filename": "n", "wing": "a", "hall": "facts",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||||
@@ -98,8 +97,6 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
noteToolDescriptors()[0],
|
|
||||||
noteToolDescriptors()[1],
|
|
||||||
{
|
{
|
||||||
"name": "brain_tunnel",
|
"name": "brain_tunnel",
|
||||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||||
@@ -323,15 +320,6 @@ func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMe
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
return nil, fmt.Errorf("id or path is required")
|
return nil, fmt.Errorf("id or path is required")
|
||||||
}
|
}
|
||||||
// Containment: the store resolves any brain-relative path, and
|
|
||||||
// parseFrontmatter returns the WHOLE file as body when there is no
|
|
||||||
// frontmatter fence — so without this, brain_get reads verbatim session
|
|
||||||
// transcripts under archive/ and sessions/, which BM25 never indexes and
|
|
||||||
// which nobody therefore thinks of as reachable. Same allowlist as
|
|
||||||
// brain_read (brain#4).
|
|
||||||
if _, err := notes.Resolve(s.brainDir, target, notes.ScopeNotes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
note, err := s.store.Get(ctx, target)
|
note, err := s.store.Get(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ type rpcError struct {
|
|||||||
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
brainDir string
|
brainDir string
|
||||||
govAudit capture.AuditSink
|
|
||||||
govNotify govNotifier
|
|
||||||
pipeline pipeline.Config
|
pipeline pipeline.Config
|
||||||
llm pipeline.CompleteFunc
|
llm pipeline.CompleteFunc
|
||||||
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
||||||
@@ -277,15 +275,6 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainPromote(ctx, args)
|
return s.brainPromote(ctx, args)
|
||||||
case "capture":
|
case "capture":
|
||||||
return s.brainCapture(ctx, args)
|
return s.brainCapture(ctx, args)
|
||||||
case "brain_read":
|
|
||||||
return s.brainRead(ctx, args)
|
|
||||||
case "brain_edit":
|
|
||||||
return s.brainEdit(ctx, args)
|
|
||||||
case "brain_edit_root":
|
|
||||||
// Dispatches but is intentionally absent from tools/list — see
|
|
||||||
// tools_notes.go for why a governance-file editor must not be
|
|
||||||
// discoverable by a model.
|
|
||||||
return s.brainEditRoot(ctx, args)
|
|
||||||
case "brain_index":
|
case "brain_index":
|
||||||
return s.brainIndex(ctx, args)
|
return s.brainIndex(ctx, args)
|
||||||
case "brain_tunnel":
|
case "brain_tunnel":
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||||
"brain_pending", "brain_promote",
|
"brain_pending", "brain_promote",
|
||||||
"brain_read", "brain_edit",
|
|
||||||
"brain_tunnel",
|
"brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||||
@@ -70,13 +69,6 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
// a caller verb is cognitive load with no decision behind it (ADR-0012
|
// a caller verb is cognitive load with no decision behind it (ADR-0012
|
||||||
// rule 4, #78). The capability stays reachable — see the admin test below.
|
// rule 4, #78). The capability stays reachable — see the admin test below.
|
||||||
assert.NotContains(t, names, "brain_index")
|
assert.NotContains(t, names, "brain_index")
|
||||||
|
|
||||||
// brain_edit_root is de-surfaced for a sharper reason than brain_index:
|
|
||||||
// brain content is ingested from the outside world and reaches a model's
|
|
||||||
// context, so a listed governance-file editor would be a durable
|
|
||||||
// prompt-injection path — one poisoned page rewriting the CLAUDE.md a
|
|
||||||
// .dispatch-allow agent later loads as instructions.
|
|
||||||
assert.NotContains(t, names, "brain_edit_root")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBrainIndexStaysCallableForAdminUseAfterDeSurfacing(t *testing.T) {
|
func TestBrainIndexStaysCallableForAdminUseAfterDeSurfacing(t *testing.T) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func captureToolDescriptor() map[string]any {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"text": str("the insight body"), "wing": str("brain wing"),
|
"text": str("the insight body"), "wing": str("brain wing"),
|
||||||
"hall": str("brain hall (facts/decisions/failures/hypotheses/sources)"),
|
"hall": str("brain hall (facts/decisions/failures/hypotheses/sources)"),
|
||||||
"supersede_slug": str("optional: slug of a prior note to revise in place instead of creating"),
|
"supersede_slug": str("optional: slug of a prior note to revise in place instead of creating"),
|
||||||
},
|
},
|
||||||
"required": []string{"text", "wing", "hall"},
|
"required": []string{"text", "wing", "hall"},
|
||||||
@@ -41,7 +41,7 @@ func captureToolDescriptor() map[string]any {
|
|||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"repo": str("gitea repo (owner is always mathias)"), "action": str("create|close|comment"),
|
"repo": str("gitea repo (owner is always mathias)"), "action": str("create|close|comment"),
|
||||||
"number": map[string]any{"type": "integer", "description": "issue number (close/comment)"},
|
"number": map[string]any{"type": "integer", "description": "issue number (close/comment)"},
|
||||||
"title": str("issue title (create)"), "body": str("issue/comment body"),
|
"title": str("issue title (create)"), "body": str("issue/comment body"),
|
||||||
},
|
},
|
||||||
"required": []string{"repo", "action"},
|
"required": []string{"repo", "action"},
|
||||||
}
|
}
|
||||||
@@ -51,14 +51,14 @@ func captureToolDescriptor() map[string]any {
|
|||||||
"context": map[string]any{
|
"context": map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"harness": str("descriptive harness label (telemetry only, never a gate input)"),
|
"harness": str("descriptive harness label (telemetry only, never a gate input)"),
|
||||||
"session_ref": str("optional session reference"), "fidelity": str("live-capture|transcript-parse|agent-runlog"),
|
"session_ref": str("optional session reference"), "fidelity": str("live-capture|transcript-parse|agent-runlog"),
|
||||||
"actor": str("acting user/agent"), "classification": str("caller-declared sensitivity: public|internal|confidential"),
|
"actor": str("acting user/agent"), "classification": str("caller-declared sensitivity: public|internal|confidential"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"insights": map[string]any{"type": "array", "items": insightItem},
|
"insights": map[string]any{"type": "array", "items": insightItem},
|
||||||
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
||||||
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(schema)
|
b, _ := json.Marshal(schema)
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
package mcp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// noteToolDescriptors are the model-facing halves of brain#4.
|
|
||||||
//
|
|
||||||
// brain_edit_root is deliberately absent: it dispatches but is not listed, the
|
|
||||||
// same de-surfacing ADR-0012 rule 4 applies to brain_index (#78). Hiding the
|
|
||||||
// verb matters here for a specific reason — brain content is ingested from the
|
|
||||||
// outside world and reaches a model's context, so a listed governance-file
|
|
||||||
// editor is a durable prompt-injection path: one poisoned page could rewrite
|
|
||||||
// the CLAUDE.md that a .dispatch-allow agent later obeys. A human who knows
|
|
||||||
// the tool exists can still call it; a model reading tools/list cannot
|
|
||||||
// discover it.
|
|
||||||
func noteToolDescriptors() []map[string]any {
|
|
||||||
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
|
||||||
schema := func(required []string, props map[string]any) json.RawMessage {
|
|
||||||
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
return []map[string]any{
|
|
||||||
{
|
|
||||||
"name": "brain_read",
|
|
||||||
"description": "Return a note's raw markdown verbatim — no synthesis, no excerpting. " +
|
|
||||||
"Use this to obtain the exact text before brain_edit; never build old_str from a " +
|
|
||||||
"brain_query excerpt, which is truncated and normalised and will not match.",
|
|
||||||
"inputSchema": schema([]string{"path"}, map[string]any{
|
|
||||||
"path": str("brain-relative path under knowledge/ or wiki/, e.g. wiki/homelab/facts/x.md"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "brain_edit",
|
|
||||||
"description": "Replace exactly one occurrence of old_str with new_str in a note, leaving " +
|
|
||||||
"every other byte identical. Fails loudly if old_str is absent or matches more than " +
|
|
||||||
"once — extend old_str until it is unique. Does NOT stamp updated_at or supersedes " +
|
|
||||||
"(brain_update does); it is a byte-level patch, not a metadata operation.",
|
|
||||||
"inputSchema": schema([]string{"path", "old_str", "new_str"}, map[string]any{
|
|
||||||
"path": str("brain-relative path under knowledge/ or wiki/"),
|
|
||||||
"old_str": str("exact text to replace; must occur exactly once"),
|
|
||||||
"new_str": str("replacement text; empty string deletes old_str"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- hyperguild#97: governance-rewrite observability -----------------------
|
|
||||||
|
|
||||||
// govNotifier raises an out-of-band alert. Declared here rather than importing
|
|
||||||
// internal/audit so the MCP package keeps depending on capture alone.
|
|
||||||
type govNotifier interface {
|
|
||||||
Notify(ctx context.Context, msg string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithGovernanceObserver attaches the audit sink and notifier that observe a
|
|
||||||
// successful brain_edit_root. Both are optional; either may be nil.
|
|
||||||
func (s *Server) WithGovernanceObserver(sink capture.AuditSink, n govNotifier) {
|
|
||||||
s.govAudit, s.govNotify = sink, n
|
|
||||||
}
|
|
||||||
|
|
||||||
type brainReadArgs struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) brainRead(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
var a brainReadArgs
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
content, err := notes.Read(s.brainDir, a.Path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return json.Marshal(map[string]any{"path": a.Path, "content": string(content)})
|
|
||||||
}
|
|
||||||
|
|
||||||
type brainEditArgs struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
OldStr string `json:"old_str"`
|
|
||||||
NewStr string `json:"new_str"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// brainEdit is the model-facing editor: notes only.
|
|
||||||
//
|
|
||||||
// Deliberately NOT audited (#97). A note edit is already fully reconstructible
|
|
||||||
// from git history, which carries the same before/after bytes plus a diff.
|
|
||||||
// Governance files get the extra trail because they change what later agents
|
|
||||||
// obey — the asymmetry is about instruction-carrying content, not about how
|
|
||||||
// hard the change is to reconstruct. Auditing every note edit would also bury
|
|
||||||
// the rare governance record under routine traffic, which is how an alert
|
|
||||||
// stops being read.
|
|
||||||
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
_, out, err := s.editWithScope(args, notes.ScopeNotes)
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
|
|
||||||
// the authenticated principal: only the static local-CLI token may call it, so
|
|
||||||
// a claude.ai-origin session — the one that may have just read an untrusted
|
|
||||||
// page — cannot rewrite the instructions later agents load.
|
|
||||||
func (s *Server) brainEditRoot(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
||||||
info, ok := ctx.Value(principalKey).(principalInfo)
|
|
||||||
if !ok || !info.viaStatic {
|
|
||||||
return nil, fmt.Errorf("brain_edit_root requires the local static principal; " +
|
|
||||||
"a governance file is not editable from a model-facing origin")
|
|
||||||
}
|
|
||||||
res, out, err := s.editWithScope(args, notes.ScopeRoot)
|
|
||||||
if err != nil {
|
|
||||||
// A refusal is not a rewrite. Recording or alerting on one would cry
|
|
||||||
// wolf, and the gate already logs its own denials.
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
// A durable instruction rewrite should be loud, not silent.
|
|
||||||
slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out))
|
|
||||||
s.observeGovernanceEdit(ctx, info.principal, res)
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// observeGovernanceEdit records and alerts on a rewrite that already landed.
|
|
||||||
//
|
|
||||||
// Best-effort by construction: the bytes are written and brain-sync will commit
|
|
||||||
// them within the quarter hour, so failing the call here would report a
|
|
||||||
// non-rewrite to the caller while the rewrite sits on disk. A failure to
|
|
||||||
// observe is therefore logged at ERROR — the one case where the trail itself is
|
|
||||||
// what broke, and the only remaining signal.
|
|
||||||
func (s *Server) observeGovernanceEdit(ctx context.Context, principal string, res notes.Result) {
|
|
||||||
// Path and hashes only. The notification leaves the cluster for a phone
|
|
||||||
// via a third-party topic; a hash identifies the rewrite, the bytes would
|
|
||||||
// leak whatever was written into an instruction file.
|
|
||||||
msg := fmt.Sprintf("brain_edit_root applied\npath=%s\nprincipal=%s\nbefore=%s\nafter=%s",
|
|
||||||
res.Path, principal, res.PrevContentHash, res.ContentHash)
|
|
||||||
|
|
||||||
if s.govNotify != nil {
|
|
||||||
if err := s.govNotify.Notify(ctx, msg); err != nil {
|
|
||||||
slog.Error("brain_edit_root notify failed", "path", res.Path, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if s.govAudit == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Internal, not Confidential: a governance file is not client data, and
|
|
||||||
// Confidential would make DegradingSink REFUSE to record while loki is
|
|
||||||
// down rather than buffer — losing exactly the record that matters most.
|
|
||||||
outcome, err := s.govAudit.Reserve(ctx, classification.Internal)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("brain_edit_root audit reserve failed", "path", res.Path, "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entry := capture.AuditEntry{
|
|
||||||
Timestamp: time.Now().UTC(),
|
|
||||||
Principal: principal,
|
|
||||||
Actor: "brain_edit_root",
|
|
||||||
Harness: "mcp",
|
|
||||||
EffectiveClassification: classification.Internal.String(),
|
|
||||||
Items: []string{res.Path},
|
|
||||||
SecurityEvents: []string{fmt.Sprintf(
|
|
||||||
"governance file rewritten: %s before=%s after=%s",
|
|
||||||
res.Path, res.PrevContentHash, res.ContentHash)},
|
|
||||||
}
|
|
||||||
if err := s.govAudit.Record(ctx, entry, outcome); err != nil {
|
|
||||||
slog.Error("brain_edit_root audit record failed", "path", res.Path, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.Result, json.RawMessage, error) {
|
|
||||||
var a brainEditArgs
|
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
|
||||||
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
|
|
||||||
}
|
|
||||||
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
|
||||||
if err != nil {
|
|
||||||
return notes.Result{}, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reindex only when the edit could have changed what the index reads.
|
|
||||||
// BuildWingIndex consumes title/created_at from the frontmatter block;
|
|
||||||
// BM25 walks the filesystem live and embeddings are mtime-driven, so an
|
|
||||||
// unconditional rebuild would buy nothing and dirty a second file —
|
|
||||||
// _index.md being precisely the class brain-sync already needs a bespoke
|
|
||||||
// conflict resolver for.
|
|
||||||
if wing := wingOf(res.Path); wing != "" && touchesFrontmatter(a.OldStr, a.NewStr) {
|
|
||||||
if ierr := brain.BuildWingIndex(s.brainDir, wing); ierr != nil {
|
|
||||||
// Best-effort, matching every other index hook here: the write
|
|
||||||
// already landed and will be committed, so failing the call would
|
|
||||||
// make the caller retry an edit whose old_str no longer exists.
|
|
||||||
res.ReindexError = ierr.Error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out, merr := json.Marshal(res)
|
|
||||||
return res, out, merr
|
|
||||||
}
|
|
||||||
|
|
||||||
// wingOf returns the wing for a wiki path, or "" for knowledge/ and root.
|
|
||||||
func wingOf(rel string) string {
|
|
||||||
parts := strings.Split(rel, "/")
|
|
||||||
if len(parts) < 3 || parts[0] != "wiki" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// touchesFrontmatter reports whether either side of the edit mentions a field
|
|
||||||
// the wing index reads.
|
|
||||||
func touchesFrontmatter(oldStr, newStr string) bool {
|
|
||||||
for _, s := range []string{oldStr, newStr} {
|
|
||||||
if strings.Contains(s, "title:") || strings.Contains(s, "created_at:") || strings.Contains(s, "---") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
// ingestion/internal/mcp/tools_notes_test.go
|
|
||||||
package mcp_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- brain#4: brain_read / brain_edit through the JSON-RPC surface --------
|
|
||||||
|
|
||||||
func newBrainRepo(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/homelab/facts/n.md"),
|
|
||||||
[]byte("---\ntitle: 'n'\n---\n\nalpha\nbeta\n"), 0o644))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# rules\n"), 0o644))
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
func call(t *testing.T, srv *mcp.Server, name string, args map[string]any) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
|
||||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
||||||
"params": map[string]any{"name": name, "arguments": args},
|
|
||||||
}))
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
var resp map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
|
|
||||||
// The Go functions are tested in internal/notes; this pins the transport,
|
|
||||||
// because a tool that works in-process and is unreachable over JSON-RPC
|
|
||||||
// is indistinguishable from a working one in a unit test.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_read", map[string]any{"path": "wiki/homelab/facts/n.md"})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
|
||||||
|
|
||||||
resp = call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA",
|
|
||||||
})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "---\ntitle: 'n'\n---\n\nalpha\nBETA\n", string(got),
|
|
||||||
"only the matched bytes may change")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit", map[string]any{
|
|
||||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error", "brain_edit must not reach CLAUDE.md")
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "# rules\n", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEditRoot_RefusedWithoutTheLocalStaticPrincipal(t *testing.T) {
|
|
||||||
// No principal in context is the model-facing case (and the default in
|
|
||||||
// this test server): the privileged door must stay shut.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_edit_root", map[string]any{
|
|
||||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error")
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "# rules\n", string(got), "governance file must be untouched")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainGet_CannotReachSessionTranscripts(t *testing.T) {
|
|
||||||
// Pre-existing hole, not introduced here: api.ReadNote resolves any
|
|
||||||
// brain-relative path and parseFrontmatter returns the WHOLE file as body
|
|
||||||
// when there is no frontmatter fence. archive/ holds verbatim Claude
|
|
||||||
// session transcripts (up to 500 KB) that BM25 never indexes, so they are
|
|
||||||
// invisible to search and trivially readable by path — and CLAUDE.md
|
|
||||||
// documents the layout.
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "archive/claude-sessions/koala"), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(
|
|
||||||
filepath.Join(dir, "archive/claude-sessions/koala/session-1.md"),
|
|
||||||
[]byte("verbatim transcript with tool output\n"), 0o644))
|
|
||||||
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
resp := call(t, srv, "brain_get", map[string]any{"path": "archive/claude-sessions/koala/session-1.md"})
|
|
||||||
|
|
||||||
require.Contains(t, resp, "error", "brain_get must not read outside knowledge/ and wiki/")
|
|
||||||
assert.NotContains(t, string(mustJSON(t, resp)), "verbatim transcript")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainGet_StillReadsNotes(t *testing.T) {
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
|
|
||||||
resp := call(t, srv, "brain_get", map[string]any{"path": "wiki/homelab/facts/n.md"})
|
|
||||||
assert.NotContains(t, resp, "error", "%v", resp["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustJSON(t *testing.T, v any) []byte {
|
|
||||||
t.Helper()
|
|
||||||
b, err := json.Marshal(v)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- hyperguild#97: a governance rewrite must be observed, not just gated ----
|
|
||||||
|
|
||||||
// govSink captures audit records. Governance edits are rare, so the whole
|
|
||||||
// history is kept and asserted on rather than sampled.
|
|
||||||
type govSink struct {
|
|
||||||
entries []capture.AuditEntry
|
|
||||||
outcomes []capture.AuditOutcome
|
|
||||||
recErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *govSink) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
|
||||||
return capture.AuditCentral, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *govSink) Record(_ context.Context, e capture.AuditEntry, o capture.AuditOutcome) error {
|
|
||||||
s.entries = append(s.entries, e)
|
|
||||||
s.outcomes = append(s.outcomes, o)
|
|
||||||
return s.recErr
|
|
||||||
}
|
|
||||||
|
|
||||||
type govNotifier struct {
|
|
||||||
msgs []string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *govNotifier) Notify(_ context.Context, msg string) error {
|
|
||||||
n.msgs = append(n.msgs, msg)
|
|
||||||
return n.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// govServer wires capture (which is what re-derives the static principal) plus
|
|
||||||
// the #97 observer, and returns the brain dir so the test can read the file.
|
|
||||||
func govServer(t *testing.T) (*mcp.Server, string, *govSink, *govNotifier) {
|
|
||||||
t.Helper()
|
|
||||||
dir := newBrainRepo(t)
|
|
||||||
cfg, err := classification.Load(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
|
||||||
|
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
|
||||||
srv.WithCapture(svc, nil, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
|
||||||
sink, notifier := &govSink{}, &govNotifier{}
|
|
||||||
srv.WithGovernanceObserver(sink, notifier)
|
|
||||||
return srv, dir, sink, notifier
|
|
||||||
}
|
|
||||||
|
|
||||||
// authCall is call() with an Authorization header, so the static principal is
|
|
||||||
// present and brain_edit_root's gate opens.
|
|
||||||
func authCall(t *testing.T, srv *mcp.Server, authz, name string, args map[string]any) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
|
||||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
||||||
"params": map[string]any{"name": name, "arguments": args},
|
|
||||||
}))
|
|
||||||
if authz != "" {
|
|
||||||
req.Header.Set("Authorization", authz)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
var resp map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func sha256hex(s string) string {
|
|
||||||
sum := sha256.Sum256([]byte(s))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEditRoot_RecordsAnAuditEntryNamingPrincipalPathAndBothHashes(t *testing.T) {
|
|
||||||
// The gate proves a rewrite was ALLOWED. Only a record proves one HAPPENED
|
|
||||||
// — and brain-sync will commit it within 15 minutes authored `brain-sync`,
|
|
||||||
// so git history cannot tell it apart from routine note traffic.
|
|
||||||
srv, dir, sink, _ := govServer(t)
|
|
||||||
|
|
||||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
|
||||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
|
||||||
})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
|
||||||
|
|
||||||
require.Len(t, sink.entries, 1, "exactly one audit record per governance rewrite")
|
|
||||||
e := sink.entries[0]
|
|
||||||
assert.Equal(t, "local-cli", e.Principal)
|
|
||||||
assert.Contains(t, e.Items, "CLAUDE.md", "the record must name what was rewritten")
|
|
||||||
|
|
||||||
joined := strings.Join(e.SecurityEvents, " ")
|
|
||||||
assert.Contains(t, joined, sha256hex("# rules\n"), "before-hash must be recorded")
|
|
||||||
assert.Contains(t, joined, sha256hex("# rewritten\n"), "after-hash must be recorded")
|
|
||||||
assert.NotZero(t, e.Timestamp)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "# rewritten\n", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEditRoot_NotifiesWithPathAndHashesButNeverContent(t *testing.T) {
|
|
||||||
srv, _, _, notifier := govServer(t)
|
|
||||||
|
|
||||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
|
||||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
|
||||||
})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
|
||||||
|
|
||||||
require.Len(t, notifier.msgs, 1)
|
|
||||||
msg := notifier.msgs[0]
|
|
||||||
assert.Contains(t, msg, "CLAUDE.md")
|
|
||||||
assert.Contains(t, msg, "local-cli")
|
|
||||||
assert.Contains(t, msg, sha256hex("# rules\n"))
|
|
||||||
assert.Contains(t, msg, sha256hex("# rewritten\n"))
|
|
||||||
|
|
||||||
// The notification travels to a phone through a third-party topic. A hash
|
|
||||||
// identifies a rewrite; the bytes would leak whatever was written.
|
|
||||||
assert.NotContains(t, msg, "# rewritten", "no file content in the notification body")
|
|
||||||
assert.NotContains(t, msg, "# rules")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEditRoot_RefusedProducesNoRecordNoNotificationNoWrite(t *testing.T) {
|
|
||||||
srv, dir, sink, notifier := govServer(t)
|
|
||||||
|
|
||||||
// No Authorization header → no static principal → the gate stays shut.
|
|
||||||
resp := authCall(t, srv, "", "brain_edit_root", map[string]any{
|
|
||||||
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error")
|
|
||||||
|
|
||||||
assert.Empty(t, sink.entries, "a refusal is not a rewrite — recording one would cry wolf")
|
|
||||||
assert.Empty(t, notifier.msgs)
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "# rules\n", string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEditRoot_FailedEditProducesNoRecordNoNotification(t *testing.T) {
|
|
||||||
// Authorised but no match: the door opened and nothing went through it.
|
|
||||||
srv, _, sink, notifier := govServer(t)
|
|
||||||
|
|
||||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
|
||||||
"path": "CLAUDE.md", "old_str": "absent-from-the-file", "new_str": "x",
|
|
||||||
})
|
|
||||||
require.Contains(t, resp, "error")
|
|
||||||
|
|
||||||
assert.Empty(t, sink.entries)
|
|
||||||
assert.Empty(t, notifier.msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBrainEdit_OnOrdinaryNotesIsNotAudited(t *testing.T) {
|
|
||||||
// The decided asymmetry (#97): note edits are covered by git history, which
|
|
||||||
// carries the same before/after bytes with a diff. Governance files get the
|
|
||||||
// extra trail because they change what later agents obey, not because they
|
|
||||||
// are harder to reconstruct.
|
|
||||||
srv, _, sink, notifier := govServer(t)
|
|
||||||
|
|
||||||
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit", map[string]any{
|
|
||||||
"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA",
|
|
||||||
})
|
|
||||||
require.NotContains(t, resp, "error", "%v", resp["error"])
|
|
||||||
|
|
||||||
assert.Empty(t, sink.entries, "note edits must not fill the governance audit trail")
|
|
||||||
assert.Empty(t, notifier.msgs)
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
package notes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxNoteBytes caps what Read will hand back. Generous for knowledge/ and
|
|
||||||
// wiki/ (the largest note today is far under it) and small enough that a
|
|
||||||
// pathological file cannot flood a caller's context.
|
|
||||||
const maxNoteBytes = 256 << 10
|
|
||||||
|
|
||||||
// Errors callers distinguish.
|
|
||||||
var (
|
|
||||||
ErrNoMatch = errors.New("notes: old_str not found")
|
|
||||||
ErrAmbiguous = errors.New("notes: old_str matches more than once")
|
|
||||||
ErrNotUTF8 = errors.New("notes: content is not valid UTF-8")
|
|
||||||
ErrGitBusy = errors.New("notes: a git operation is in progress")
|
|
||||||
ErrTooLarge = errors.New("notes: file exceeds the readable size limit")
|
|
||||||
ErrDegenerate = errors.New("notes: edit is degenerate")
|
|
||||||
ErrInConflict = errors.New("notes: file contains unresolved conflict markers")
|
|
||||||
)
|
|
||||||
|
|
||||||
// pathLocks serialises edits per file within this process. The flock below
|
|
||||||
// covers the cross-process case (two pods share the hostPath during a rolling
|
|
||||||
// update, and brain-fix/brain-enrich write the same tree).
|
|
||||||
var pathLocks sync.Map // string -> *sync.Mutex
|
|
||||||
|
|
||||||
// Result is what a successful edit reports.
|
|
||||||
type Result struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
// PrevContentHash is the sha256 of the file as it was before this edit.
|
|
||||||
// Paired with ContentHash it identifies a rewrite in an audit trail or an
|
|
||||||
// alert without carrying any of the bytes (hyperguild#97).
|
|
||||||
PrevContentHash string `json:"prev_content_hash"`
|
|
||||||
ContentHash string `json:"content_hash"`
|
|
||||||
BytesBefore int `json:"bytes_before"`
|
|
||||||
BytesAfter int `json:"bytes_after"`
|
|
||||||
ReindexError string `json:"reindex_error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read returns a note's bytes verbatim.
|
|
||||||
//
|
|
||||||
// No frontmatter parsing, no excerpting, no normalisation: the entire purpose
|
|
||||||
// is to give a caller an exact basis for an edit. An excerpt from brain_query
|
|
||||||
// would be truncated and normalised, and every old_str built from one would
|
|
||||||
// miss.
|
|
||||||
func Read(brainDir, path string) ([]byte, error) {
|
|
||||||
t, err := Resolve(brainDir, path, ScopeNotes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return readContained(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadScoped is Read for a caller already holding the privileged scope.
|
|
||||||
func ReadScoped(brainDir, path string, scope Scope) ([]byte, error) {
|
|
||||||
t, err := Resolve(brainDir, path, scope)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return readContained(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readContained(t Target) ([]byte, error) {
|
|
||||||
fi, err := os.Stat(t.Abs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("notes: stat %s: %w", t.Rel, err)
|
|
||||||
}
|
|
||||||
if fi.Size() > maxNoteBytes {
|
|
||||||
return nil, fmt.Errorf("%w: %s is %d bytes (limit %d)", ErrTooLarge, t.Rel, fi.Size(), maxNoteBytes)
|
|
||||||
}
|
|
||||||
b, err := os.ReadFile(t.Abs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("notes: read %s: %w", t.Rel, err)
|
|
||||||
}
|
|
||||||
// The MCP response marshals this into a JSON string, and encoding/json
|
|
||||||
// substitutes U+FFFD for invalid UTF-8 — silently returning different
|
|
||||||
// bytes than the file holds, which defeats "verbatim" entirely.
|
|
||||||
if !utf8.Valid(b) {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrNotUTF8, t.Rel)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit replaces exactly one occurrence of oldStr with newStr, in place.
|
|
||||||
//
|
|
||||||
// Fails loudly and writes nothing on: zero matches, more than one match
|
|
||||||
// (counted with overlap), degenerate arguments, invalid UTF-8, a git operation
|
|
||||||
// in flight, or unresolved conflict markers in the target.
|
|
||||||
func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
|
|
||||||
t, err := Resolve(brainDir, path, scope)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if oldStr == "" {
|
|
||||||
return Result{}, fmt.Errorf("%w: old_str is empty", ErrDegenerate)
|
|
||||||
}
|
|
||||||
if oldStr == newStr {
|
|
||||||
return Result{}, fmt.Errorf("%w: old_str equals new_str", ErrDegenerate)
|
|
||||||
}
|
|
||||||
if !utf8.ValidString(newStr) {
|
|
||||||
return Result{}, fmt.Errorf("%w: new_str", ErrNotUTF8)
|
|
||||||
}
|
|
||||||
// brain#2 guard: refuse to write to a tree the sync does not commit from.
|
|
||||||
if err := assertSyncSource(brainDir); err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if err := assertGitIdle(brainDir); err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
mu := lockFor(t.Abs)
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
unlock, err := flockFile(t.Abs)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
// Read INSIDE the lock: the match count must be evaluated against the
|
|
||||||
// bytes we are about to overwrite, or a concurrent edit is silently lost
|
|
||||||
// while both callers are told they succeeded.
|
|
||||||
before, err := readContained(t)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
if hasConflictMarkers(before) {
|
|
||||||
return Result{}, fmt.Errorf("%w: %s", ErrInConflict, t.Rel)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch n := countOverlapping(string(before), oldStr); {
|
|
||||||
case n == 0:
|
|
||||||
return Result{}, fmt.Errorf("%w in %s (0 matches)%s", ErrNoMatch, t.Rel, crlfHint(string(before), oldStr))
|
|
||||||
case n > 1:
|
|
||||||
return Result{}, fmt.Errorf("%w in %s (%d matches) — extend old_str until it is unique",
|
|
||||||
ErrAmbiguous, t.Rel, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
after := strings.Replace(string(before), oldStr, newStr, 1)
|
|
||||||
if err := writeAtomic(t.Abs, []byte(after)); err != nil {
|
|
||||||
return Result{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sum := sha256.Sum256([]byte(after))
|
|
||||||
prev := sha256.Sum256(before)
|
|
||||||
return Result{
|
|
||||||
Path: t.Rel,
|
|
||||||
PrevContentHash: hex.EncodeToString(prev[:]),
|
|
||||||
ContentHash: hex.EncodeToString(sum[:]),
|
|
||||||
BytesBefore: len(before),
|
|
||||||
BytesAfter: len(after),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// countOverlapping counts occurrences allowing overlap. bytes.Count does not:
|
|
||||||
// "aa" occurs once in "aaa" by its reckoning, so a Count-based uniqueness
|
|
||||||
// check accepts an ambiguous edit as unique.
|
|
||||||
func countOverlapping(s, sub string) int {
|
|
||||||
var n, from int
|
|
||||||
for {
|
|
||||||
i := strings.Index(s[from:], sub)
|
|
||||||
if i < 0 {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
n++
|
|
||||||
from += i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// crlfHint reports whether the caller's old_str would have matched if line
|
|
||||||
// endings agreed. It never normalises — that would break byte-identity — but
|
|
||||||
// an opaque "no match" is unfixable from the caller's side, and a stripped \r
|
|
||||||
// is the likeliest cause.
|
|
||||||
func crlfHint(content, oldStr string) string {
|
|
||||||
if strings.Contains(content, "\r\n") && !strings.Contains(oldStr, "\r\n") {
|
|
||||||
if strings.Contains(strings.ReplaceAll(content, "\r\n", "\n"), oldStr) {
|
|
||||||
return " — the file uses CRLF line endings and old_str does not; include the \\r"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasConflictMarkers(b []byte) bool {
|
|
||||||
s := string(b)
|
|
||||||
return strings.Contains(s, "\n<<<<<<< ") || strings.HasPrefix(s, "<<<<<<< ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertGitIdle refuses while brain-sync (or brain-fix, or a human) has a git
|
|
||||||
// operation in flight. An edit landing between `git merge` and its abort is
|
|
||||||
// either hard-reset away or committed as a wrongly resolved conflict — and the
|
|
||||||
// caller was told it succeeded.
|
|
||||||
func assertGitIdle(brainDir string) error {
|
|
||||||
for _, marker := range []string{"MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "index.lock"} {
|
|
||||||
if _, err := os.Stat(filepath.Join(brainDir, ".git", marker)); err == nil {
|
|
||||||
return fmt.Errorf("%w (.git/%s present) — retry after the sync settles", ErrGitBusy, marker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lockFor(abs string) *sync.Mutex {
|
|
||||||
v, _ := pathLocks.LoadOrStore(abs, &sync.Mutex{})
|
|
||||||
return v.(*sync.Mutex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// flockFile takes an advisory lock covering other processes sharing the
|
|
||||||
// hostPath — during a RollingUpdate two ingestion pods do.
|
|
||||||
func flockFile(abs string) (func(), error) {
|
|
||||||
f, err := os.OpenFile(abs+".lock", os.O_CREATE|os.O_RDWR, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("notes: lock %s: %w", filepath.Base(abs), err)
|
|
||||||
}
|
|
||||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
|
||||||
_ = f.Close()
|
|
||||||
return nil, fmt.Errorf("notes: flock %s: %w", filepath.Base(abs), err)
|
|
||||||
}
|
|
||||||
return func() {
|
|
||||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
|
||||||
_ = f.Close()
|
|
||||||
_ = os.Remove(abs + ".lock")
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeAtomic replaces the file via a same-directory temp + rename, then
|
|
||||||
// fsyncs the directory so the rename survives a node crash.
|
|
||||||
//
|
|
||||||
// Same directory because a rename across the hostPath boundary fails with
|
|
||||||
// EXDEV; the ".tmp" suffix because the tree's .gitignore already covers it, so
|
|
||||||
// a leaked temp is not committed by `git add -A`.
|
|
||||||
func writeAtomic(abs string, content []byte) error {
|
|
||||||
dir := filepath.Dir(abs)
|
|
||||||
tmp, err := os.CreateTemp(dir, filepath.Base(abs)+".*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("notes: temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
|
|
||||||
|
|
||||||
if _, err := tmp.Write(content); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("notes: write temp: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("notes: sync temp: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("notes: close temp: %w", err)
|
|
||||||
}
|
|
||||||
// CreateTemp makes 0600; every other writer of this tree uses 0644 and
|
|
||||||
// other uids read it.
|
|
||||||
if err := os.Chmod(tmpName, 0o644); err != nil {
|
|
||||||
return fmt.Errorf("notes: chmod temp: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, abs); err != nil {
|
|
||||||
return fmt.Errorf("notes: rename: %w", err)
|
|
||||||
}
|
|
||||||
return fsyncDir(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fsyncDir(dir string) error {
|
|
||||||
d, err := os.Open(dir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("notes: open dir: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = d.Close() }()
|
|
||||||
if err := d.Sync(); err != nil {
|
|
||||||
return fmt.Errorf("notes: fsync dir: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
package notes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func seed(t *testing.T, dir, rel, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
abs := filepath.Join(dir, rel)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return abs
|
|
||||||
}
|
|
||||||
|
|
||||||
func read(t *testing.T, abs string) string {
|
|
||||||
t.Helper()
|
|
||||||
b, err := os.ReadFile(abs)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRead_ReturnsVerbatimBytes(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
const body = "---\ntitle: 'x'\n---\n\nbody with double spaces\r\nand a CRLF line\nno trailing newline"
|
|
||||||
seed(t, dir, "knowledge/verbatim.md", body)
|
|
||||||
|
|
||||||
got, err := Read(dir, "knowledge/verbatim.md")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Read: %v", err)
|
|
||||||
}
|
|
||||||
if string(got) != body {
|
|
||||||
t.Errorf("Read returned %q, want the file byte-for-byte", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRead_RefusesNonUTF8RatherThanCorruptingIt(t *testing.T) {
|
|
||||||
// The response is marshalled into a JSON string, and encoding/json
|
|
||||||
// replaces invalid UTF-8 with U+FFFD. Returning it would mean every
|
|
||||||
// old_str the caller derives from that region can never match, and
|
|
||||||
// pasting it back would write the corruption to disk permanently.
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/bad.md", "ok \xff\xfe broken\n")
|
|
||||||
|
|
||||||
if _, err := Read(dir, "knowledge/bad.md"); err == nil {
|
|
||||||
t.Fatal("Read accepted invalid UTF-8; it must refuse rather than silently substitute")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_ReplacesExactlyOnceAndLeavesEveryOtherByteAlone(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
const body = "---\ntitle: 'x'\n---\n\nalpha\nbeta\ngamma\n"
|
|
||||||
abs := seed(t, dir, "knowledge/n.md", body)
|
|
||||||
|
|
||||||
res, err := Edit(dir, "knowledge/n.md", "beta", "BETA", ScopeNotes)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Edit: %v", err)
|
|
||||||
}
|
|
||||||
want := strings.Replace(body, "beta", "BETA", 1)
|
|
||||||
if got := read(t, abs); got != want {
|
|
||||||
t.Errorf("file = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
if res.ContentHash == "" {
|
|
||||||
t.Error("result should carry a content hash for read-after-write confirmation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_RefusesAmbiguousAndAbsentMatchesWithoutWriting(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
const body = "alpha\nalpha\n"
|
|
||||||
abs := seed(t, dir, "knowledge/n.md", body)
|
|
||||||
|
|
||||||
cases := []struct{ name, old, want string }{
|
|
||||||
{"two matches", "alpha", "2"},
|
|
||||||
{"no match", "delta", "0"},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
_, err := Edit(dir, "knowledge/n.md", tc.old, "x", ScopeNotes)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("want a loud refusal")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), tc.want) {
|
|
||||||
t.Errorf("error must state the match count, got %v", err)
|
|
||||||
}
|
|
||||||
if got := read(t, abs); got != body {
|
|
||||||
t.Errorf("file changed on a refused edit: %q", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_CountsOverlappingMatches(t *testing.T) {
|
|
||||||
// bytes.Count counts NON-overlapping occurrences: "aa" in "aaa" is 1, so
|
|
||||||
// a Count-based uniqueness check calls an ambiguous edit unique.
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/n.md", "aaa\n")
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "aa", "b", ScopeNotes); err == nil {
|
|
||||||
t.Fatal("overlapping matches must be reported as ambiguous")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_RejectsDegenerateArguments(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "", "x", ScopeNotes); err == nil {
|
|
||||||
t.Error("empty old_str must be rejected")
|
|
||||||
}
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "alpha", "alpha", ScopeNotes); err == nil {
|
|
||||||
t.Error("a no-op edit must be rejected rather than burning a sync cycle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_DeletionIsLegal(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
abs := seed(t, dir, "knowledge/n.md", "keep\ndrop\n")
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "drop\n", "", ScopeNotes); err != nil {
|
|
||||||
t.Fatalf("Edit: %v", err)
|
|
||||||
}
|
|
||||||
if got := read(t, abs); got != "keep\n" {
|
|
||||||
t.Errorf("file = %q, want %q", got, "keep\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_ReportsWhenOnlyLineEndingsDiffer(t *testing.T) {
|
|
||||||
// The likeliest real failure: the caller's old_str lost its \r. Never
|
|
||||||
// normalise silently — that breaks byte-identity — but say so, because an
|
|
||||||
// opaque "no match" is unfixable from the caller's side.
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/n.md", "alpha\r\nbeta\r\n")
|
|
||||||
|
|
||||||
_, err := Edit(dir, "knowledge/n.md", "alpha\nbeta", "x", ScopeNotes)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("want a refusal")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "line ending") {
|
|
||||||
t.Errorf("error should point at CRLF, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_RefusesWhileGitIsMidOperation(t *testing.T) {
|
|
||||||
// brain-sync runs `git merge -X ours origin/main`, and aborts on conflict
|
|
||||||
// with a hard reset. An edit landing inside that window is either erased
|
|
||||||
// or committed as a wrongly-resolved conflict, and the caller was told it
|
|
||||||
// succeeded.
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
||||||
|
|
||||||
for _, marker := range []string{"MERGE_HEAD", "index.lock", "REBASE_HEAD"} {
|
|
||||||
t.Run(marker, func(t *testing.T) {
|
|
||||||
p := filepath.Join(dir, ".git", marker)
|
|
||||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = os.Remove(p) }()
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err == nil {
|
|
||||||
t.Errorf("edit accepted while %s exists", marker)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_RefusesAFileHoldingConflictMarkers(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/n.md", "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> origin/main\n")
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "ours", "x", ScopeNotes); err == nil {
|
|
||||||
t.Fatal("editing one side of an unresolved conflict must be refused")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_ConcurrentEditsBothApply(t *testing.T) {
|
|
||||||
// Read-modify-write with no lock loses one update AND reports success for
|
|
||||||
// both. old_str uniqueness does not protect against it: uniqueness is
|
|
||||||
// evaluated against the stale read.
|
|
||||||
dir := newBrain(t)
|
|
||||||
abs := seed(t, dir, "knowledge/n.md", "alpha\nbeta\n")
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
errs := make([]error, 2)
|
|
||||||
for i, pair := range [][2]string{{"alpha", "ALPHA"}, {"beta", "BETA"}} {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int, old, new string) {
|
|
||||||
defer wg.Done()
|
|
||||||
_, errs[i] = Edit(dir, "knowledge/n.md", old, new, ScopeNotes)
|
|
||||||
}(i, pair[0], pair[1])
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
for i, err := range errs {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("edit %d failed: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := read(t, abs); got != "ALPHA\nBETA\n" {
|
|
||||||
t.Errorf("file = %q, want both edits applied", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_LeavesNoTemporaryFileBehind(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(filepath.Join(dir, "knowledge"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.Name() != "note.md" && e.Name() != "n.md" {
|
|
||||||
t.Errorf("stray file left in the tree: %q (git add -A would commit it)", e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_PreservesReadableMode(t *testing.T) {
|
|
||||||
// Other uids write this tree (brain-fix, brain-enrich). os.CreateTemp
|
|
||||||
// yields 0600 and rename preserves it, which would break them silently.
|
|
||||||
dir := newBrain(t)
|
|
||||||
abs := seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
fi, err := os.Stat(abs)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if fi.Mode().Perm() != 0o644 {
|
|
||||||
t.Errorf("mode = %v, want 0644", fi.Mode().Perm())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEdit_RefusesNonUTF8Replacement(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
||||||
|
|
||||||
if _, err := Edit(dir, "knowledge/n.md", "alpha", "bad \xff", ScopeNotes); err == nil {
|
|
||||||
t.Fatal("new_str with invalid UTF-8 must be refused, not written")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
// Package notes provides containment-checked read and surgical edit of brain
|
|
||||||
// notes on the live tree that brain-sync commits from.
|
|
||||||
//
|
|
||||||
// Everything here is defensive for one reason: brain content is ingested from
|
|
||||||
// the outside world, that content reaches a model's context via brain_query /
|
|
||||||
// brain_context, and these primitives are callable by that model. A path bug
|
|
||||||
// is therefore not a path bug — it is an outside author choosing a file.
|
|
||||||
package notes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scope is which part of the tree a caller is allowed to touch.
|
|
||||||
type Scope int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ScopeNotes is the model-facing scope: markdown under knowledge/ and
|
|
||||||
// wiki/ only.
|
|
||||||
ScopeNotes Scope = iota
|
|
||||||
// ScopeRoot admits the named governance files and NOTHING else. It exists so
|
|
||||||
// there is an MCP path to CLAUDE.md at all — the absence of one is what
|
|
||||||
// pushed a session to edit via the gitea REST API on 2026-06-16, which
|
|
||||||
// never reached the live tree and wedged brain-sync for hours. It is NOT
|
|
||||||
// reachable from the model-facing toolset; see tools_notes.go.
|
|
||||||
ScopeRoot
|
|
||||||
)
|
|
||||||
|
|
||||||
// rootFiles is an exact-name allowlist, deliberately not a "root *.md" glob.
|
|
||||||
//
|
|
||||||
// Two reasons. log.md is gitignored, so an edit to it applies to the live tree
|
|
||||||
// and never reaches gitea — the exact shape of brain#2, admitted by a glob. And
|
|
||||||
// a glob auto-admits whatever governance file appears next (AGENTS.md, a
|
|
||||||
// bootstrap doc) with nobody reviewing that decision.
|
|
||||||
var rootFiles = map[string]bool{"CLAUDE.md": true}
|
|
||||||
|
|
||||||
// noteDirs are the two trees that hold notes.
|
|
||||||
var noteDirs = []string{"knowledge/", "wiki/"}
|
|
||||||
|
|
||||||
// deniedPrefixes are refused in every scope, independent of the allowlist.
|
|
||||||
//
|
|
||||||
// A static backstop rather than a consequence of the allowlist being right:
|
|
||||||
// .git/hooks/post-commit is executed by brain-sync inside a pod holding the
|
|
||||||
// Gitea push credentials, and archive/ + sessions/ hold verbatim session
|
|
||||||
// transcripts that BM25 never indexes and so nobody thinks of as reachable.
|
|
||||||
var deniedPrefixes = []string{".git/", "archive/", "sessions/", "raw/", "training-data/", ".obsidian/"}
|
|
||||||
|
|
||||||
// Errors are sentinels so callers can distinguish a refusal from an I/O fault.
|
|
||||||
var (
|
|
||||||
ErrOutsideBrain = errors.New("notes: path escapes the brain dir")
|
|
||||||
ErrNotAllowed = errors.New("notes: path is not editable through this tool")
|
|
||||||
ErrNotSyncSource = errors.New("notes: brain dir is not the sync source")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Target is a resolved, contained path.
|
|
||||||
type Target struct {
|
|
||||||
Rel string // brain-relative, cleaned
|
|
||||||
Abs string // absolute, symlink-checked
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve canonicalises path, then checks it against the scope's allowlist,
|
|
||||||
// then re-checks containment after symlink evaluation.
|
|
||||||
//
|
|
||||||
// Order matters and is the point: a prefix test on the raw argument accepts
|
|
||||||
// "knowledge/../.git/hooks/post-commit", because Clean only collapses it
|
|
||||||
// afterwards.
|
|
||||||
func Resolve(brainDir, path string, scope Scope) (Target, error) {
|
|
||||||
// Note: the sync-source assertion lives on the WRITE path (Edit), not
|
|
||||||
// here. brain#2 was an edit landing where the sync could not commit it; a
|
|
||||||
// read from such a tree is merely a read.
|
|
||||||
|
|
||||||
// 1. Canonicalise. Rooting at "/" makes Clean collapse every ".." that
|
|
||||||
// would otherwise escape, and ToSlash normalises separators first.
|
|
||||||
clean := strings.TrimPrefix(filepath.Clean("/"+filepath.ToSlash(path)), "/")
|
|
||||||
if clean == "" || clean == "." {
|
|
||||||
return Target{}, fmt.Errorf("%w: empty path", ErrNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Static denials, before any allowlist reasoning.
|
|
||||||
for _, d := range deniedPrefixes {
|
|
||||||
if strings.HasPrefix(clean+"/", d) {
|
|
||||||
return Target{}, fmt.Errorf("%w: %q is not note content", ErrNotAllowed, clean)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(filepath.Base(clean), ".") {
|
|
||||||
return Target{}, fmt.Errorf("%w: dotfiles are not editable", ErrNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Scope allowlist.
|
|
||||||
if err := allow(clean, scope); err != nil {
|
|
||||||
return Target{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Containment, lexically.
|
|
||||||
abs := filepath.Join(brainDir, filepath.FromSlash(clean))
|
|
||||||
if !within(brainDir, abs) {
|
|
||||||
return Target{}, fmt.Errorf("%w: %q", ErrOutsideBrain, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Containment again, after resolving symlinks. The tree is Syncthing
|
|
||||||
// managed, so a link can appear without anyone adding one on purpose;
|
|
||||||
// a symlinked note or directory would otherwise read and write outside
|
|
||||||
// the brain entirely.
|
|
||||||
if err := assertNoSymlinkEscape(brainDir, abs); err != nil {
|
|
||||||
return Target{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return Target{Rel: clean, Abs: abs}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow enforces the scope's allowlist. The two scopes are DISJOINT, not
|
|
||||||
// nested: the privileged root scope reaches governance files and nothing else,
|
|
||||||
// so the tool that carries the higher privilege carries the smaller reach.
|
|
||||||
func allow(clean string, scope Scope) error {
|
|
||||||
if scope == ScopeRoot {
|
|
||||||
if !rootFiles[clean] {
|
|
||||||
return fmt.Errorf("%w: %q is not a governance file", ErrNotAllowed, clean)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !isNotePath(clean) {
|
|
||||||
return fmt.Errorf("%w: %q is outside knowledge/ and wiki/", ErrNotAllowed, clean)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNotePath(clean string) bool {
|
|
||||||
var inDir bool
|
|
||||||
for _, d := range noteDirs {
|
|
||||||
if strings.HasPrefix(clean, d) {
|
|
||||||
inDir = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !inDir || filepath.Ext(clean) != ".md" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// _index.md is generated per wing, and brain-sync's conflict resolver
|
|
||||||
// deliberately resolves it in origin's favour — an edit here is reverted
|
|
||||||
// by design, so accepting one would be a lie.
|
|
||||||
return filepath.Base(clean) != "_index.md"
|
|
||||||
}
|
|
||||||
|
|
||||||
func within(root, abs string) bool {
|
|
||||||
rel, err := filepath.Rel(root, abs)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertNoSymlinkEscape resolves the deepest existing ancestor of abs and
|
|
||||||
// requires it to still be inside brainDir. The file itself need not exist
|
|
||||||
// (a create is legal); its directory chain must be real and contained.
|
|
||||||
func assertNoSymlinkEscape(brainDir, abs string) error {
|
|
||||||
if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return fmt.Errorf("%w: %q is a symlink", ErrNotAllowed, abs)
|
|
||||||
}
|
|
||||||
|
|
||||||
realRoot, err := filepath.EvalSymlinks(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("notes: resolve brain dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Dir(abs)
|
|
||||||
for {
|
|
||||||
resolved, err := filepath.EvalSymlinks(dir)
|
|
||||||
if err == nil {
|
|
||||||
if !within(realRoot, resolved) {
|
|
||||||
return fmt.Errorf("%w: %q resolves outside the brain dir", ErrOutsideBrain, dir)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("notes: resolve %q: %w", dir, err)
|
|
||||||
}
|
|
||||||
parent := filepath.Dir(dir)
|
|
||||||
if parent == dir {
|
|
||||||
return fmt.Errorf("%w: no existing ancestor for %q", ErrOutsideBrain, abs)
|
|
||||||
}
|
|
||||||
dir = parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertSyncSource refuses a brain dir that is not a git working tree.
|
|
||||||
//
|
|
||||||
// brain#2 was an edit landing on a directory the sync does not commit from.
|
|
||||||
// That cannot be prevented by review — a wrong INGEST_BRAIN_DIR in a future
|
|
||||||
// manifest reintroduces it while every unit test still passes — so it is
|
|
||||||
// asserted at runtime, the same check brain-sync itself makes before running.
|
|
||||||
func assertSyncSource(brainDir string) error {
|
|
||||||
if fi, err := os.Stat(filepath.Join(brainDir, ".git")); err != nil || !fi.IsDir() {
|
|
||||||
return fmt.Errorf("%w: %q has no .git, so an edit there would never be committed",
|
|
||||||
ErrNotSyncSource, brainDir)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
package notes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newBrain lays out a tree shaped like the live one: a git dir, note dirs,
|
|
||||||
// and the non-note areas that must never be reachable.
|
|
||||||
func newBrain(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
for _, d := range []string{
|
|
||||||
".git/hooks", "knowledge", "wiki/homelab/facts", "archive/claude-sessions/koala",
|
|
||||||
"sessions", "raw", ".obsidian",
|
|
||||||
} {
|
|
||||||
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
write := func(p, s string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, p), []byte(s), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
write("CLAUDE.md", "# instructions\n")
|
|
||||||
write("log.md", "ignored scratch\n")
|
|
||||||
write("knowledge/note.md", "# note\n")
|
|
||||||
write("wiki/homelab/facts/f.md", "# fact\n")
|
|
||||||
write("wiki/homelab/_index.md", "# index\n")
|
|
||||||
write("archive/claude-sessions/koala/session-1.md", "verbatim transcript\n")
|
|
||||||
write("sessions/s.jsonl", "{}\n")
|
|
||||||
write(".git/hooks/post-commit", "#!/bin/sh\n")
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolve_AcceptsNotePaths(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
for _, p := range []string{"knowledge/note.md", "wiki/homelab/facts/f.md"} {
|
|
||||||
got, err := Resolve(dir, p, ScopeNotes)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Resolve(%q): %v", p, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(got.Abs, dir) {
|
|
||||||
t.Errorf("Resolve(%q).Abs = %q, want inside %q", p, got.Abs, dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolve_RefusesEscapesAndNonNoteAreas(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
|
|
||||||
// Canonicalisation must happen BEFORE the allowlist check. A prefix test
|
|
||||||
// on the raw argument accepts every one of these: "knowledge/..%2f" style
|
|
||||||
// inputs collapse to .git, and .git/hooks/post-commit is executed by
|
|
||||||
// brain-sync inside a pod holding the Gitea push credentials.
|
|
||||||
refuse := []string{
|
|
||||||
"knowledge/../.git/hooks/post-commit",
|
|
||||||
"wiki/../.git/config",
|
|
||||||
"./knowledge/../.git/config",
|
|
||||||
"knowledge//../.git/config",
|
|
||||||
"../../etc/passwd",
|
|
||||||
"/etc/passwd",
|
|
||||||
".git/config",
|
|
||||||
"archive/claude-sessions/koala/session-1.md", // verbatim transcripts
|
|
||||||
"sessions/s.jsonl",
|
|
||||||
"raw/x.md",
|
|
||||||
".obsidian/workspace.json",
|
|
||||||
"log.md", // gitignored: an edit here would never sync (brain#2)
|
|
||||||
"wiki/homelab/_index.md", // generated; brain-sync resolves it in origin's favour
|
|
||||||
"knowledge/note.txt", // not markdown
|
|
||||||
"CLAUDE.md", // root file — not reachable in the notes scope
|
|
||||||
}
|
|
||||||
for _, p := range refuse {
|
|
||||||
if _, err := Resolve(dir, p, ScopeNotes); err == nil {
|
|
||||||
t.Errorf("Resolve(%q) = nil error, want refusal", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolve_RootScopeAdmitsOnlyNamedFiles(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
|
|
||||||
if _, err := Resolve(dir, "CLAUDE.md", ScopeRoot); err != nil {
|
|
||||||
t.Errorf("Resolve(CLAUDE.md, ScopeRoot): %v", err)
|
|
||||||
}
|
|
||||||
// log.md is the reason this is an exact-name list and not "root *.md":
|
|
||||||
// it is gitignored, so an edit would apply locally and never reach gitea.
|
|
||||||
for _, p := range []string{"log.md", "AGENTS.md", "knowledge/note.md", ".git/config"} {
|
|
||||||
if _, err := Resolve(dir, p, ScopeRoot); err == nil {
|
|
||||||
t.Errorf("Resolve(%q, ScopeRoot) = nil error, want refusal", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolve_RefusesSymlinkedTargets(t *testing.T) {
|
|
||||||
dir := newBrain(t)
|
|
||||||
outside := filepath.Join(t.TempDir(), "secret.md")
|
|
||||||
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// The tree is Syncthing-managed, so a symlink can appear without anyone
|
|
||||||
// adding one deliberately. Containment must survive it.
|
|
||||||
if err := os.Symlink(outside, filepath.Join(dir, "knowledge/link.md")); err != nil {
|
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := Resolve(dir, "knowledge/link.md", ScopeNotes); err == nil {
|
|
||||||
t.Error("Resolve followed a symlink out of the brain dir")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Symlink(filepath.Dir(outside), filepath.Join(dir, "wiki/escape")); err != nil {
|
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := Resolve(dir, "wiki/escape/secret.md", ScopeNotes); err == nil {
|
|
||||||
t.Error("Resolve followed a symlinked directory out of the brain dir")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolve_RefusesWhenTheTreeIsNotTheSyncSource(t *testing.T) {
|
|
||||||
// brain#2 was "wrote to a directory the sync does not commit from". No
|
|
||||||
// code review prevents that recurring — a wrong INGEST_BRAIN_DIR in a
|
|
||||||
// future manifest reintroduces it and every unit test still passes. So
|
|
||||||
// assert the property at runtime, the way brain-sync itself does.
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "knowledge/note.md"), []byte("x"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Reads are fine anywhere; the guard belongs to writes.
|
|
||||||
if _, err := Resolve(dir, "knowledge/note.md", ScopeNotes); err != nil {
|
|
||||||
t.Fatalf("Resolve should not require .git for a read: %v", err)
|
|
||||||
}
|
|
||||||
_, err := Edit(dir, "knowledge/note.md", "x", "y", ScopeNotes)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Edit accepted a tree with no .git — the write would never be committed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "sync source") {
|
|
||||||
t.Errorf("error should name the cause, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-62
@@ -5,29 +5,15 @@
|
|||||||
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
||||||
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
||||||
#
|
#
|
||||||
# Cursor and Aider adapters removed 2026-09-01 — neither is a supported harness
|
# Usage: ./scripts/context-sync.sh [adapter...]
|
||||||
# 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
|
# Task: task context:sync
|
||||||
#
|
#
|
||||||
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
||||||
|
|
||||||
set -euo pipefail
|
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"
|
PROJECT_FILE=".context/PROJECT.md"
|
||||||
|
SKILLS_DIR=".skills"
|
||||||
|
|
||||||
# Walk up to find root .context/AGENT.md
|
# Walk up to find root .context/AGENT.md
|
||||||
find_root_context() {
|
find_root_context() {
|
||||||
@@ -50,28 +36,19 @@ if [ ! -f "$PROJECT_FILE" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
echo " Root context: $ROOT_CONTEXT"
|
echo " Root context: $ROOT_CONTEXT"
|
||||||
else
|
else
|
||||||
echo " No root AGENT.md found (project context only)"
|
# 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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Emit root context + separator
|
# Emit root context + separator
|
||||||
@@ -118,7 +95,6 @@ generate_system_prompt() {
|
|||||||
|
|
||||||
# ── MCP config ───────────────────────────────────────────────
|
# ── MCP config ───────────────────────────────────────────────
|
||||||
generate_mcp() {
|
generate_mcp() {
|
||||||
# Ensure baseline file exists with project-specific knowledge server
|
|
||||||
if [ ! -f .context/mcp.json ]; then
|
if [ ! -f .context/mcp.json ]; then
|
||||||
cat > .context/mcp.json << 'JSON'
|
cat > .context/mcp.json << 'JSON'
|
||||||
{
|
{
|
||||||
@@ -130,47 +106,27 @@ generate_mcp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
echo " → .context/mcp.json (new)"
|
||||||
|
else
|
||||||
|
echo " → .context/mcp.json (exists, skipped)"
|
||||||
fi
|
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..."
|
echo "Syncing project context from $PROJECT_FILE..."
|
||||||
|
|
||||||
if [ ${#ADAPTERS[@]} -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
generate_claude
|
generate_claude
|
||||||
generate_agents
|
generate_agents
|
||||||
generate_system_prompt
|
generate_system_prompt
|
||||||
generate_mcp
|
generate_mcp
|
||||||
else
|
else
|
||||||
for adapter in "${ADAPTERS[@]}"; do
|
for adapter in "$@"; do
|
||||||
case "$adapter" in
|
case "$adapter" in
|
||||||
claude) generate_claude ;;
|
claude) generate_claude ;;
|
||||||
agents) generate_agents ;;
|
agents) generate_agents ;;
|
||||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||||
mcp) generate_mcp ;;
|
mcp) generate_mcp ;;
|
||||||
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
|
*) echo "Unknown adapter: $adapter" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user