Compare commits

..
Author SHA1 Message Date
mathias ce365eee44 feat(pipeline): skip extraction when the source has not changed (#105)
Ingest now stamps source_hash — sha256 of the ingested body, frontmatter
excluded — onto the source page, and compares it before doing any work. An
unchanged source returns Skipped with no LLM call at all.

This is cost and signal, not correctness: #103 already made a re-ingest
byte-identical, so a repeated run was harmless but not free — a full
extraction over every chunk to produce a result that merges to nothing.
Building this first would have put idempotency in the guard rather than in
the write, which is the wrong dependency direction.

Body-only hashing on purpose. A re-crawl that stamps a new fetch date has
not changed the source, and treating that as drift would re-extract the
whole corpus every time a crawler ran. A missing or unreadable hash always
means "extract": a missed skip costs one extraction, a wrong skip loses an
update silently, and silence is the failure mode this repo keeps paying
for.

The skip is reported (Result.Skipped plus a warning naming the hash), not
silent — a run that does nothing must say so, or "the pipeline is fine" and
"the pipeline stopped working" look identical (#107).

Storing the hash required settling who owns which frontmatter field. #103
made the on-disk block win wholesale, which protects human-set metadata
from a regenerating writer but froze fields the generator legitimately
owns. wiki.Merge now refreshes an explicit generatorOwned allowlist —
last_updated and source_hash — and keeps everything else on disk. An
allowlist rather than a carve-out, so a field nobody has thought about
stays human-owned by default. That also retires the last_updated
limitation #103 shipped with.

Refs #105, #103, #107, brain#21.
2026-08-22 16:15:56 +02:00
mathias 60a8801a1d feat(pipeline): ingest revises the existing page instead of clobbering it
CI / Lint / Test / Vet (pull_request) Successful in 38s
CI / Mirror to GitHub (pull_request) Skipped
Compile-on-ingest, first half. buildAndWrite did an unconditional
os.WriteFile, so re-ingesting a source replaced its page wholesale and
discarded everything accumulated since — including human edits.
hyperguild#43 recorded the symptom as "re-ingest overwrites wholesale";
the framing that ingest "appends" understates it.

Now the write reads what is on disk and merges the fresh extraction into
it, asymmetrically in the existing page's favour: on-disk frontmatter and
on-disk sections win, extraction-only sections are added, bullet lists
union. An extraction can add to a note but never silently rewrite what a
human put there, which is what makes it safe on every ingest rather than
behind a review gate.

Two supporting fixes in wiki.Merge, both required for this to be safe to
run repeatedly:

- append sections (Open Questions, Updates, Evolving Notes) concatenated
  wholesale, which is correct exactly once. Re-ingesting an unchanged
  source would grow them every run until the page was unreadable. Now only
  genuinely new lines are appended. Line-level rather than block-level,
  because the common case is a re-ingest carrying one new line alongside
  several it already has, and a block-level check would duplicate the old
  ones every time.

- rebuildContent kept whatever blank lines a section was parsed with and
  added its own, so every merge pass grew the page by a blank line —
  invisible in a diff viewer, fatal to byte-stability. Section bodies are
  now normalised. Re-ingesting unchanged content is byte-identical, so
  brain-sync does not commit noise that hides real edits.

The drift guard that would otherwise catch repeated ingests (hashing the
source body, B3) does not exist yet, so idempotency holds here rather than
upstream.

Known limitation, deliberate: on-disk frontmatter wins, so last_updated
keeps its first-ingest date on a revised page. The alternative is this
function editing frontmatter it does not own, and no lint reads that field.

Refs hyperguild#43.
2026-08-22 13:19:14 +02:00
43 changed files with 592 additions and 1151 deletions
-23
View File
@@ -7,29 +7,6 @@
"supervisor": { "supervisor": {
"url": "http://localhost:3200/mcp", "url": "http://localhost:3200/mcp",
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection" "description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
},
"brain": {
"type": "http",
"url": "https://koala.tail30472e.ts.net:8443/mcp",
"headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
}
},
"gitea": {
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
"type": "http",
"url": "https://koala.tail30472e.ts.net:8446/mcp",
"headers": {
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
}
},
"infra": {
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
"type": "http",
"url": "https://koala.tail30472e.ts.net:8444/mcp",
"headers": {
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
}
} }
} }
} }
+19 -75
View File
@@ -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:**
-6
View File
@@ -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
-34
View File
@@ -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
View File
@@ -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.
-33
View File
@@ -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
+19 -75
View File
@@ -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:**
+40
View File
@@ -76,6 +76,46 @@ that prompted the question is answered by naming it here instead.
Full reasoning, including the reversal trigger: brain Full reasoning, including the reversal trigger: brain
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`. `wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
## Ingest revises, it does not overwrite
Re-ingesting a source **merges into the page already on disk** rather than
replacing it. Previously the write was unconditional, so a second ingest of the
same source discarded everything accumulated since — including hand-written
sections.
The merge is deliberately asymmetric in the existing page's favour:
| On the page | In the fresh extraction | Result |
|---|---|---|
| a section | the same section | **the on-disk version wins** |
| — | a new section | added |
| a bullet list (`Sources`, `Key Claims`, …) | more bullets | unioned, no duplicates |
| an append section (`Open Questions`, `Updates`, …) | more lines | only genuinely new lines appended |
| frontmatter | frontmatter | **the on-disk frontmatter wins** |
So an extraction can add to a note but never silently rewrite what a human put
there, which is what makes it safe to run on every ingest rather than behind a
review gate.
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
does not commit noise that hides real edits.
Two fields are **generator-owned** and do get refreshed on a revise:
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
an explicit allowlist, so a field nobody has thought about stays human-owned.
## Re-ingesting an unchanged source is skipped
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
excluded — on the source page. A later ingest of the same source compares it
first and skips extraction entirely when it matches, reporting the skip rather
than doing it silently.
Body-only is deliberate: a re-crawl that stamps a new fetch date has not changed
the source, and treating that as drift would re-extract the corpus every time a
crawler ran. A missing or unreadable hash always means "extract" — a missed skip
costs one extraction, a wrong skip loses an update.
## Phase 1 tools (available now) ## Phase 1 tools (available now)
| Tool | What it does | | Tool | What it does |
+10 -29
View File
@@ -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']]"
``` ```
+6 -35
View File
@@ -236,11 +236,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)
} }
@@ -396,39 +391,15 @@ func main() {
} }
host := envOr("CLAUDE_INGEST_HOST", systemHostname()) host := envOr("CLAUDE_INGEST_HOST", systemHostname())
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
// Project directories excluded from ingestion entirely, as globs
// against the project directory name — e.g.
// "*cobalt-dingo*,*claude-worktrees-bridge-cse*".
//
// This is a veto list, deliberately named as one. It complements the
// client-name scrubber rather than replacing it: the scrubber drops
// individual TURNS whose content matches, while this never opens a
// project's files at all. Sessions working on live company financials
// are excluded here because their content is confidential in ways no
// pattern reliably recognises, and their learnings are captured
// deliberately through the session-close ritual instead.
//
// Caveat worth knowing: a project directory is derived from the
// session's WORKING DIRECTORY, not from the repositories it touches.
// A session run from ~/dev that happens to read a skipped repo will
// NOT be excluded by this. The scrubber remains the control for
// content; this is a control for whole workspaces.
skipProjects := splitList(os.Getenv("CLAUDE_INGEST_SKIP_PROJECTS"))
if len(skipProjects) > 0 {
logger.Info("claudewatcher: projects excluded from ingestion", "patterns", skipProjects)
}
sink := &claudeSink{brainDir: brainDir, logger: logger} sink := &claudeSink{brainDir: brainDir, logger: logger}
go func() { go func() {
if err := claudewatcher.Watch(ctx, claudewatcher.Config{ if err := claudewatcher.Watch(ctx, claudewatcher.Config{
SessionsDir: claudeDir, SessionsDir: claudeDir,
Host: host, Host: host,
Interval: interval, Interval: interval,
Sink: sink, Sink: sink,
Cursors: cursorStore, Cursors: cursorStore,
Logger: logger, Logger: logger,
SkipProjects: skipProjects,
}); err != nil && err != context.Canceled { }); err != nil && err != context.Canceled {
logger.Error("claudewatcher exited", "err", err) logger.Error("claudewatcher exited", "err", err)
} }
+2 -2
View File
@@ -28,8 +28,8 @@ type FileBuffer struct {
} }
type bufferLine struct { type bufferLine struct {
ID string `json:"id"` ID string `json:"id"`
Entry capture.AuditEntry `json:"entry"` Entry capture.AuditEntry `json:"entry"`
} }
// NewFileBuffer returns a buffer backed by path. The parent directory is // NewFileBuffer returns a buffer backed by path. The parent directory is
+4 -4
View File
@@ -46,10 +46,10 @@ func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
// unwritableBuffer always reports it cannot be written (floor condition). // unwritableBuffer always reports it cannot be written (floor condition).
type unwritableBuffer struct{} type unwritableBuffer struct{}
func (unwritableBuffer) Writable() error { return errors.New("disk full") } func (unwritableBuffer) Writable() error { return errors.New("disk full") }
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") } func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil } func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
func (unwritableBuffer) Confirm(string) error { return nil } func (unwritableBuffer) Confirm(string) error { return nil }
func newFileBuffer(t *testing.T) *audit.FileBuffer { func newFileBuffer(t *testing.T) *audit.FileBuffer {
t.Helper() t.Helper()
-157
View File
@@ -1,157 +0,0 @@
package brain
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// wikilinkRE matches [[target]] and [[target|Display Text]].
var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`)
// fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a
// syntax example rather than a link. Same exclusion brain-gardener's audit
// applies on the read side, so the write gate and the audit agree on what
// counts as a link at all.
var (
fencedCodeRE = regexp.MustCompile("(?s)```.*?```")
inlineCodeRE = regexp.MustCompile("`[^`\n]*`")
)
// StripUnresolvedLinks unwraps every [[wikilink]] in content whose target
// resolves to no entry under knowledge/ or wiki/, leaving the link's display
// text (or its target) as plain prose. It returns the rewritten content and
// the sorted, de-duplicated targets it unwrapped.
//
// WHY UNWRAP RATHER THAN REJECT THE WRITE
// Agents write [[...]] around anything that feels related — a skill name, a
// repo, an agent-local memory file — and none of those are brain entries.
// Every one of those becomes a permanent [high] broken-link finding that no
// deterministic Fixer can repair, because there is no target to repoint to:
// the brain-audit digest grew from 19 to 63 high findings between 2026-08-26
// and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write
// instead would push the failure onto claudewatcher's unattended ingest path,
// where a refused write is a lost learning. Unwrapping keeps the author's
// words, keeps the write, and keeps the graph honest.
//
// An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir)
// returns content unchanged: nothing resolving is a bootstrap state, not
// evidence that every target was invented.
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
index, err := entryIndex(brainDir)
if err != nil {
return "", nil, err
}
if len(index) == 0 {
return content, nil, nil
}
code := codeSpans(content)
stripped := map[string]struct{}{}
var b strings.Builder
last := 0
for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) {
start, end := m[0], m[1]
if overlaps(start, end, code) {
continue
}
target := strings.TrimSpace(content[m[2]:m[3]])
if _, ok := index[linkKey(target)]; ok {
continue
}
display := target
if m[4] >= 0 {
if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" {
display = d
}
}
b.WriteString(content[last:start])
b.WriteString(display)
last = end
stripped[target] = struct{}{}
}
if last == 0 {
return content, nil, nil
}
b.WriteString(content[last:])
targets := make([]string, 0, len(stripped))
for t := range stripped {
targets = append(targets, t)
}
sort.Strings(targets)
return b.String(), targets, nil
}
// linkedTrees are the two trees that hold linkable entries — the same pair
// package notes admits under ScopeNotes. Duplicated rather than imported
// because that list is unexported there and this is a read-only mirror.
var linkedTrees = []string{"knowledge", "wiki"}
// entryIndex maps every form a wikilink may name an entry by — its bare slug
// and its path form (wiki/<wing>/<hall>/<slug>.md -> wing/hall/slug) — to a
// present marker. Mirrors brain-gardener's resolver so an accepted link is
// one the audit will also resolve.
func entryIndex(brainDir string) (map[string]struct{}, error) {
index := map[string]struct{}{}
for _, dir := range linkedTrees {
root := filepath.Join(brainDir, dir)
if _, err := os.Stat(root); os.IsNotExist(err) {
continue
} else if err != nil {
return nil, fmt.Errorf("stat %s: %w", dir, err)
}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %s: %w", dir, err)
}
}
return index, nil
}
// linkKey normalises a link target for index lookup: an exact path form is
// tried as written, and anything else falls back to its final segment.
func linkKey(target string) string {
return strings.TrimSuffix(target, ".md")
}
// codeSpans returns the byte ranges of fenced blocks and inline code spans.
func codeSpans(content string) [][2]int {
var spans [][2]int
for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) {
spans = append(spans, [2]int{m[0], m[1]})
}
for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) {
spans = append(spans, [2]int{m[0], m[1]})
}
return spans
}
// overlaps reports whether [start,end) intersects any span.
func overlaps(start, end int, spans [][2]int) bool {
for _, s := range spans {
if start < s[1] && end > s[0] {
return true
}
}
return false
}
@@ -1,93 +0,0 @@
package brain_test
import (
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// seedCorpus writes the two entries every linkcheck case resolves against.
func seedCorpus(t *testing.T, dir string) {
t.Helper()
seedNote(t, dir, "wiki/homelab/facts/real-entry.md",
"---\nwing: homelab\nhall: facts\ntitle: 'Real entry'\n---\n\nbody.\n")
seedNote(t, dir, "knowledge/legacy-entry.md",
"---\ntitle: 'Legacy entry'\n---\n\nbody.\n")
}
func TestStripUnresolvedLinks(t *testing.T) {
tests := []struct {
name string
body string
want string
stripped []string
}{
{
name: "bare slug that resolves is kept",
body: "See [[real-entry]] for the rule.",
want: "See [[real-entry]] for the rule.",
},
{
name: "path-style target that resolves is kept",
body: "See [[homelab/facts/real-entry]].",
want: "See [[homelab/facts/real-entry]].",
},
{
name: "knowledge/ entry resolves by bare slug",
body: "See [[legacy-entry]].",
want: "See [[legacy-entry]].",
},
{
name: "unresolvable target is unwrapped to plain text",
body: "Related: [[secret-hygiene]].",
want: "Related: secret-hygiene.",
stripped: []string{"secret-hygiene"},
},
{
name: "unresolvable aliased target keeps its display text",
body: "Related: [[feedback_check_host_not_assume|check the host]].",
want: "Related: check the host.",
stripped: []string{"feedback_check_host_not_assume"},
},
{
name: "link inside a fenced code block is left alone",
body: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
want: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
},
{
name: "link inside an inline code span is left alone",
body: "Write `[[wing/hall/slug]]` to link.",
want: "Write `[[wing/hall/slug]]` to link.",
},
{
name: "every unresolvable target is reported once, sorted",
body: "[[zeta-gap]] and [[alpha-gap]] and [[zeta-gap]] again.",
want: "zeta-gap and alpha-gap and zeta-gap again.",
stripped: []string{"alpha-gap", "zeta-gap"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
seedCorpus(t, dir)
got, stripped, err := brain.StripUnresolvedLinks(dir, tc.body)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
assert.Equal(t, tc.stripped, stripped)
})
}
}
func TestStripUnresolvedLinks_MissingBrainDirIsNotAGate(t *testing.T) {
// A brain dir with no knowledge//wiki/ tree yet must not turn every link
// into an unresolved one — an empty corpus is a bootstrap state, not
// evidence that the author invented the target.
got, stripped, err := brain.StripUnresolvedLinks(t.TempDir(), "See [[real-entry]].")
require.NoError(t, err)
assert.Equal(t, "See [[real-entry]].", got)
assert.Nil(t, stripped)
}
+2 -14
View File
@@ -42,12 +42,6 @@ func (s *Store) WithGraph(g graphsync.Store) *Store {
// Write creates a brain note and returns its read-after-write handle. // Write creates a brain note and returns its read-after-write handle.
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) { func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
if err != nil {
return capture.Ref{}, err
}
n.Content = content
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{ relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
Content: n.Content, Content: n.Content,
Filename: n.Filename, Filename: n.Filename,
@@ -63,19 +57,13 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error)
s.indexInGraph(ctx, "brain_write", relPath) s.indexInGraph(ctx, "brain_write", relPath)
_, _, hash, _ := api.ReadNote(s.brainDir, relPath) _, _, hash, _ := api.ReadNote(s.brainDir, relPath)
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Unlinked: unlinked}, nil return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
} }
// Update supersedes an existing note in place. slug may be a bare slug // Update supersedes an existing note in place. slug may be a bare slug
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it // (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
// contains a slash). It never creates — a missing target is an error. // contains a slash). It never creates — a missing target is an error.
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) { func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
if err != nil {
return capture.Ref{}, err
}
n.Content = content
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason} opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
if strings.Contains(slug, "/") { if strings.Contains(slug, "/") {
opts.Path = slug opts.Path = slug
@@ -92,7 +80,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur
} }
s.indexInGraph(ctx, "brain_update", relPath) s.indexInGraph(ctx, "brain_update", relPath)
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true, Unlinked: unlinked}, nil return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
} }
// Get fetches a note by id/path — the read-after-write confirmation // Get fetches a note by id/path — the read-after-write confirmation
@@ -83,45 +83,3 @@ func TestStoreGetRoundTripsHash(t *testing.T) {
assert.Equal(t, "a", note.Frontmatter["wing"]) assert.Equal(t, "a", note.Frontmatter["wing"])
assert.Contains(t, note.Body, "# Body") assert.Contains(t, note.Body, "# Body")
} }
func TestStoreWriteUnwrapsUnresolvedWikilinks(t *testing.T) {
dir := t.TempDir()
s := brainstore.New(dir)
_, err := s.Write(context.Background(), capture.Note{
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
})
require.NoError(t, err)
ref, err := s.Write(context.Background(), capture.Note{
Content: "See [[target]] and [[secret-hygiene]].\n",
Filename: "src", Wing: "a", Hall: "facts",
})
require.NoError(t, err)
assert.Equal(t, []string{"secret-hygiene"}, ref.Unlinked)
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/src.md"))
require.NoError(t, err)
assert.Contains(t, string(got), "[[target]]")
assert.Contains(t, string(got), "and secret-hygiene.")
assert.NotContains(t, string(got), "[[secret-hygiene]]")
}
func TestStoreUpdateUnwrapsUnresolvedWikilinks(t *testing.T) {
dir := t.TempDir()
s := brainstore.New(dir)
_, err := s.Write(context.Background(), capture.Note{
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
})
require.NoError(t, err)
ref, err := s.Update(context.Background(), "n", capture.Note{
Content: "Related: [[cad-babysit]].\n", Wing: "a", Hall: "facts", Reason: "r",
})
require.NoError(t, err)
assert.Equal(t, []string{"cad-babysit"}, ref.Unlinked)
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
require.NoError(t, err)
assert.Contains(t, string(got), "Related: cad-babysit.")
assert.NotContains(t, string(got), "[[cad-babysit]]")
}
-4
View File
@@ -15,10 +15,6 @@ type Ref struct {
Path string Path string
ContentHash string ContentHash string
Superseded bool Superseded bool
// Unlinked names the [[wikilink]] targets the store unwrapped to plain
// text because they resolve to no entry. Reported rather than rejected:
// see brain.StripUnresolvedLinks for why the write still lands.
Unlinked []string
} }
// StoredNote is a brain note fetched by Get: the read-after-write // StoredNote is a brain note fetched by Get: the read-after-write
@@ -38,11 +38,9 @@ func (f fakeTracker) CreateIssue(context.Context, string, string, string) (captu
} }
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
} }
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) { func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
return capture.IssueRef{}, nil return capture.IssueRef{}, nil
} }
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) { func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
return capture.IssueRef{}, nil return capture.IssueRef{}, nil
} }
@@ -185,7 +183,6 @@ type refusingAudit struct{}
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) { func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
return 0, errors.New("central audit sink unreachable") return 0, errors.New("central audit sink unreachable")
} }
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error { func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
return nil return nil
} }
@@ -1,231 +0,0 @@
package claudewatcher
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// recordingHandler captures log records so a test can assert that a file was
// never even attempted — the absence of a read error is the evidence.
type recordingHandler struct {
mu sync.Mutex
records []string
}
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
var sb strings.Builder
sb.WriteString(r.Message)
r.Attrs(func(a slog.Attr) bool {
sb.WriteString(" " + a.Key + "=" + a.Value.String())
return true
})
h.records = append(h.records, sb.String())
return nil
}
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
func (h *recordingHandler) contains(substr string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for _, r := range h.records {
if strings.Contains(r, substr) {
return true
}
}
return false
}
func session(t *testing.T, root, project, id string) string {
t.Helper()
dir := filepath.Join(root, project)
require.NoError(t, os.MkdirAll(dir, 0o755))
return writeSession(t, dir, id, []string{
`{"type":"user","sessionId":"` + id + `","message":"a prompt"}`,
`{"type":"assistant","sessionId":"` + id + `","message":{"content":[{"type":"text","text":"an answer"}]}}`,
})
}
// A named project is not ingested, and its neighbours still are.
func TestSkipProjects_skipsTheNamedProjectOnly(t *testing.T) {
tmp := t.TempDir()
session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "skipped")
session(t, tmp, "-home-mathias-dev", "kept")
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp,
Host: "koala",
Sink: sink,
SkipProjects: []string{"-home-mathias-dev-AGENTS-cobalt-dingo"},
}))
require.Len(t, sink.batches, 1)
assert.Equal(t, "kept", sink.batches[0].SessionID)
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
}
// Globs are necessary, not a convenience. A project directory is derived from
// the session's WORKING DIRECTORY, so a git worktree produces an ephemeral
// name like "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC...". An
// exact-match list could never keep up with those.
func TestSkipProjects_globMatchesEphemeralWorktreeNames(t *testing.T) {
tmp := t.TempDir()
session(t, tmp, "-home-mathias-dev--claude-worktrees-bridge-cse-017xoCEcrc35", "worktree")
session(t, tmp, "-home-mathias-dev", "kept")
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp,
Host: "koala",
Sink: sink,
SkipProjects: []string{"*claude-worktrees-bridge-cse*"},
}))
require.Len(t, sink.batches, 1)
assert.Equal(t, "kept", sink.batches[0].SessionID)
}
// The point of skipping at the directory walk: the file is never OPENED, so
// its content never enters this process. Proven by making it unreadable — a
// skipped file produces no read error because nothing tried to read it, while
// the same file in a non-skipped project does.
func TestSkipProjects_theFileIsNeverOpened(t *testing.T) {
tmp := t.TempDir()
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
require.NoError(t, os.Chmod(path, 0o000))
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
rec := &recordingHandler{}
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp,
Host: "koala",
Sink: sink,
Logger: slog.New(rec),
SkipProjects: []string{"*cobalt-dingo*"},
}))
assert.Empty(t, sink.batches)
assert.False(t, rec.contains("file failed"),
"a skipped file must not even be attempted")
}
// The control test for the one above: without the skip, the unreadable file IS
// attempted and fails. Otherwise the assertion above could pass for the wrong
// reason.
func TestSkipProjects_withoutTheSkipTheSameFileIsAttempted(t *testing.T) {
tmp := t.TempDir()
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
require.NoError(t, os.Chmod(path, 0o000))
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
rec := &recordingHandler{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp,
Host: "koala",
Sink: &memSink{},
Logger: slog.New(rec),
}))
assert.True(t, rec.contains("file failed"),
"the unreadable file must fail when not skipped — otherwise the skip test proves nothing")
}
// Patterns match the project directory NAME, not the whole path. A temp
// directory or a home directory component must not accidentally match and
// silently disable ingestion.
func TestSkipProjects_matchesTheProjectNameNotTheFullPath(t *testing.T) {
tmp := t.TempDir()
session(t, tmp, "-home-mathias-dev", "kept")
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp,
Host: "koala",
Sink: sink,
// tmp is somewhere under /tmp; this must not match the project name.
SkipProjects: []string{"*tmp*"},
}))
require.Len(t, sink.batches, 1, "a path component outside the project name must not match")
}
// An unparseable pattern must stop the watcher rather than be ignored. A
// silently dropped skip rule means ingesting what was meant to be excluded,
// which is the failure that cannot be noticed after the fact.
func TestSkipProjects_invalidPatternRefusesToRun(t *testing.T) {
tmp := t.TempDir()
session(t, tmp, "-home-mathias-dev", "kept")
sink := &memSink{}
err := TickOnce(context.Background(), Config{
SessionsDir: tmp,
Host: "koala",
Sink: sink,
SkipProjects: []string{"[unclosed"},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "[unclosed")
assert.Empty(t, sink.batches, "nothing may be ingested while a skip rule is broken")
}
func TestSkipProjects_emptyListChangesNothing(t *testing.T) {
tmp := t.TempDir()
session(t, tmp, "-home-mathias-dev", "kept")
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: nil,
}))
require.Len(t, sink.batches, 1)
}
// The difference between SkipDir and filtering after the walk, made
// observable: an unreadable skipped directory must not disturb anything.
//
// Without SkipDir, WalkDir reads the directory's entries, gets a permission
// error, and that error aborts the ENTIRE walk — so one skipped project with
// awkward permissions would silently stop ingesting every other project.
// Returning SkipDir means its contents are never listed at all.
//
// Added after a mutation test showed the earlier assertions could not tell the
// two implementations apart.
func TestSkipProjects_anUnreadableSkippedDirDoesNotBreakTheWalk(t *testing.T) {
tmp := t.TempDir()
session(t, tmp, "-home-mathias-dev", "kept")
blocked := filepath.Join(tmp, "-home-mathias-dev-AGENTS-cobalt-dingo")
require.NoError(t, os.MkdirAll(blocked, 0o755))
_ = writeSession(t, blocked, "secret", []string{
`{"type":"user","sessionId":"secret","message":"should never be read"}`,
})
require.NoError(t, os.Chmod(blocked, 0o000))
t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) })
sink := &memSink{}
require.NoError(t, TickOnce(context.Background(), Config{
SessionsDir: tmp,
Host: "koala",
Sink: sink,
SkipProjects: []string{"*cobalt-dingo*"},
}))
require.Len(t, sink.batches, 1, "an unreadable skipped directory must not abort the walk")
assert.Equal(t, "kept", sink.batches[0].SessionID)
}
+2 -59
View File
@@ -45,25 +45,6 @@ type Config struct {
Sink Sink Sink Sink
Cursors *CursorStore Cursors *CursorStore
Logger *slog.Logger Logger *slog.Logger
// SkipProjects excludes whole project directories from ingestion, as
// filepath.Match globs tested against the project directory NAME (the
// basename, e.g. "-home-mathias-dev"), not the full path.
//
// Globs rather than exact names because a project directory is derived
// from the session's working directory, so a git worktree yields an
// ephemeral name like
// "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC..." that no
// exact-match list could keep up with.
//
// Matching directories are skipped during the directory WALK, so their
// files are never opened and their content never enters this process —
// which is a stronger property than filtering it out afterwards.
//
// An unparseable pattern stops the watcher. A silently dropped skip rule
// means ingesting what was meant to be excluded, and that is not
// noticeable after the fact.
SkipProjects []string
} }
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err() // Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
@@ -87,9 +68,6 @@ func Watch(ctx context.Context, cfg Config) error {
if cfg.Logger == nil { if cfg.Logger == nil {
cfg.Logger = slog.Default() cfg.Logger = slog.Default()
} }
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
return err
}
cfg.Logger.Info("claudewatcher: started", cfg.Logger.Info("claudewatcher: started",
"sessions_dir", cfg.SessionsDir, "sessions_dir", cfg.SessionsDir,
"host", cfg.Host, "host", cfg.Host,
@@ -113,7 +91,7 @@ func Watch(ctx context.Context, cfg Config) error {
// runTick is one polling pass. Exposed (lowercase) for tests via // runTick is one polling pass. Exposed (lowercase) for tests via
// TickOnce. // TickOnce.
func runTick(ctx context.Context, cfg Config) { func runTick(ctx context.Context, cfg Config) {
files, err := listSessionFiles(cfg.SessionsDir, cfg.SkipProjects, cfg.Logger) files, err := listSessionFiles(cfg.SessionsDir)
if err != nil { if err != nil {
cfg.Logger.Warn("claudewatcher: list session files", "err", err) cfg.Logger.Warn("claudewatcher: list session files", "err", err)
return return
@@ -141,52 +119,17 @@ func TickOnce(ctx context.Context, cfg Config) error {
if cfg.Logger == nil { if cfg.Logger == nil {
cfg.Logger = slog.Default() cfg.Logger = slog.Default()
} }
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
return err
}
runTick(ctx, cfg) runTick(ctx, cfg)
return nil return nil
} }
// validateSkipPatterns rejects a pattern filepath.Match cannot parse. func listSessionFiles(root string) ([]string, error) {
//
// Checked up front rather than per file: a bad pattern that only failed at
// match time would be reported once per session file per tick, and — worse —
// filepath.Match returns an error alongside a false match, so the directory
// would be ingested while the log complained.
func validateSkipPatterns(patterns []string) error {
for _, p := range patterns {
if _, err := filepath.Match(p, "probe"); err != nil {
return fmt.Errorf("invalid skip-project pattern %q: %w", p, err)
}
}
return nil
}
// skipProject reports whether a project directory name matches any pattern.
func skipProject(name string, patterns []string) bool {
for _, p := range patterns {
// The error case is already excluded by validateSkipPatterns.
if ok, _ := filepath.Match(p, name); ok {
return true
}
}
return false
}
func listSessionFiles(root string, skip []string, log *slog.Logger) ([]string, error) {
var out []string var out []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
} }
if d.IsDir() { if d.IsDir() {
// SkipDir, so nothing inside is even listed. The files are never
// opened and their content never reaches this process.
if path != root && skipProject(d.Name(), skip) {
log.Info("claudewatcher: project skipped by configuration", "project", d.Name())
return filepath.SkipDir
}
return nil return nil
} }
if !strings.HasSuffix(path, ".jsonl") { if !strings.HasSuffix(path, ".jsonl") {
+4 -15
View File
@@ -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
} }
-75
View File
@@ -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")
}
+3 -22
View File
@@ -233,15 +233,6 @@ type brainWriteArgs struct {
Hall string `json:"hall,omitempty"` Hall string `json:"hall,omitempty"`
} }
// unlinkedNote explains the `unlinked` field to the caller that caused it.
// The write landed; what is gone is the [[...]] around a target that names no
// brain entry. Said here rather than only in a doc comment because the caller
// is a model that will otherwise repeat it on the next write.
const unlinkedNote = "these [[wikilink]] targets resolve to no brain entry, " +
"so they were unwrapped to plain text. Link only entries that exist " +
"(a filename under knowledge/ or wiki/); write the target entry first if " +
"you want the link, and keep skills, repos and agent-local memory files as prose."
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
var a brainWriteArgs var a brainWriteArgs
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
@@ -262,12 +253,7 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash} return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
if len(ref.Unlinked) > 0 {
out["unlinked"] = ref.Unlinked
out["unlinked_note"] = unlinkedNote
}
return json.Marshal(out)
} }
type brainUpdateArgs struct { type brainUpdateArgs struct {
@@ -311,14 +297,9 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := map[string]any{ return json.Marshal(map[string]any{
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded, "id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
} })
if len(ref.Unlinked) > 0 {
out["unlinked"] = ref.Unlinked
out["unlinked_note"] = unlinkedNote
}
return json.Marshal(out)
} }
type brainGetArgs struct { type brainGetArgs struct {
-2
View File
@@ -134,11 +134,9 @@ type stubTracker struct{}
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) { func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
return capture.IssueRef{}, nil return capture.IssueRef{}, nil
} }
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) { func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
return capture.IssueRef{}, nil return capture.IssueRef{}, nil
} }
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) { func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
return capture.IssueRef{}, nil return capture.IssueRef{}, nil
} }
@@ -28,11 +28,9 @@ type capFakeTracker struct{}
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) { func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
} }
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) { func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
return capture.IssueRef{}, nil return capture.IssueRef{}, nil
} }
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) { func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
return capture.IssueRef{}, nil return capture.IssueRef{}, nil
} }
+3
View File
@@ -76,6 +76,9 @@ func buildFrontmatter(rp RawPage, date string) string {
} }
fmt.Fprintf(&sb, "date_ingested: %s\n", date) fmt.Fprintf(&sb, "date_ingested: %s\n", date)
fmt.Fprintf(&sb, "last_updated: %s\n", date) fmt.Fprintf(&sb, "last_updated: %s\n", date)
if rp.SourceHash != "" {
fmt.Fprintf(&sb, "source_hash: %s\n", rp.SourceHash)
}
if rp.Source != "" { if rp.Source != "" {
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source)) fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
} }
+13 -3
View File
@@ -20,9 +20,13 @@ type RawPage struct {
// ingested content's own frontmatter (see parseContentFrontmatter) — never // ingested content's own frontmatter (see parseContentFrontmatter) — never
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM // set by the LLM. json:"-" keeps them immune to same-named keys the LLM
// might emit. Only meaningful for Type == "source". // might emit. Only meaningful for Type == "source".
Source string `json:"-"` // SourceHash is sha256 of the ingested BODY (frontmatter excluded), stamped
Author string `json:"-"` // so the next ingest of the same source can tell "unchanged" from "edited"
Published string `json:"-"` // without re-running extraction (#105).
SourceHash string `json:"-"`
Source string `json:"-"`
Author string `json:"-"`
Published string `json:"-"`
} }
// ParseRawPages parses LLM output as a JSON array of RawPage objects. // ParseRawPages parses LLM output as a JSON array of RawPage objects.
@@ -112,6 +116,11 @@ type sourceMeta struct {
Source string Source string
Author string Author string
Published string Published string
// BodyHash is sha256 of the content with any leading frontmatter removed.
// Body-only on purpose: a re-crawl that stamps a new fetch date has not
// changed the source, and treating that as drift would re-extract the
// whole corpus every time a crawler ran (#105).
BodyHash string
} }
// parseContentFrontmatter extracts source/author/published from a leading // parseContentFrontmatter extracts source/author/published from a leading
@@ -154,6 +163,7 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) {
if pages[i].Type != "source" { if pages[i].Type != "source" {
continue continue
} }
pages[i].SourceHash = meta.BodyHash
pages[i].Source = meta.Source pages[i].Source = meta.Source
pages[i].Author = meta.Author pages[i].Author = meta.Author
pages[i].Published = meta.Published pages[i].Published = meta.Published
+96 -2
View File
@@ -3,6 +3,8 @@ package pipeline
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -26,6 +28,11 @@ type Config struct {
type Result struct { type Result struct {
Pages []string // relative paths written (or would-be written in dry-run) Pages []string // relative paths written (or would-be written in dry-run)
Warnings []string Warnings []string
// Skipped reports that the source was byte-identical to the last ingest,
// so no extraction ran and nothing was written. Reported rather than
// silent: a run that does nothing must say so, or "the pipeline is fine"
// and "the pipeline stopped working" look the same (#105, #107).
Skipped bool
} }
// Run ingests content and writes structured wiki pages to brainDir/wiki/. // Run ingests content and writes structured wiki pages to brainDir/wiki/.
@@ -43,6 +50,18 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
sourceSlug := wiki.Slug(source) sourceSlug := wiki.Slug(source)
date := time.Now().UTC().Format("2006-01-02") date := time.Now().UTC().Format("2006-01-02")
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
// pass over every chunk to produce a result that merges to nothing (#105).
// The merge is already idempotent (#103), so this is cost and signal, not
// correctness — which is why it is safe for it to be an early return.
bodyHash := hashBody(content)
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) {
return Result{Skipped: true, Warnings: []string{
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
}}, nil
}
chunks := Chunk(content, cfg.ChunkSize) chunks := Chunk(content, cfg.ChunkSize)
var allRaw []RawPage var allRaw []RawPage
@@ -59,7 +78,9 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
allWarnings = append(allWarnings, warnings...) allWarnings = append(allWarnings, warnings...)
} }
applySourceMeta(allRaw, parseContentFrontmatter(content)) meta := parseContentFrontmatter(content)
meta.BodyHash = bodyHash
applySourceMeta(allRaw, meta)
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun) return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
} }
@@ -96,7 +117,11 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err) return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
} }
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil { content, err := reviseOrCreate(dest, page)
if err != nil {
return Result{}, err
}
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
return Result{}, fmt.Errorf("write %s: %w", page.Path, err) return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
} }
} }
@@ -115,6 +140,75 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
return Result{Pages: written, Warnings: warnings}, nil return Result{Pages: written, Warnings: warnings}, nil
} }
// hashBody returns sha256 of content with any leading frontmatter block
// removed, or "" for empty content.
//
// Body-only on purpose: a re-crawl that stamps a new fetch date has not changed
// the source, and treating that as drift would re-extract the corpus every time
// a crawler ran (#105).
func hashBody(content string) string {
body := content
if strings.HasPrefix(body, "---\n") {
if _, rest, found := strings.Cut(body[4:], "\n---"); found {
body = strings.TrimPrefix(rest, "\n")
}
}
if strings.TrimSpace(body) == "" {
return ""
}
sum := sha256.Sum256([]byte(body))
return hex.EncodeToString(sum[:])
}
// storedSourceHash reads source_hash from the source page a previous ingest
// wrote, or "" when there is no such page or no such field.
//
// Absence always means "extract". That is the fail-safe direction: a missed
// skip costs one extraction, while a wrong skip loses an update silently — and
// silence is the failure mode this repo keeps paying for.
func storedSourceHash(brainDir, sourceSlug string) string {
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
if err != nil {
return ""
}
for _, line := range strings.Split(string(raw), "\n") {
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
return strings.TrimSpace(value)
}
}
return ""
}
// reviseOrCreate returns the bytes to write for one extracted page: merged into
// whatever is already on disk, or the page as-is when the path is new.
//
// This is the compile half of compile-on-ingest. Before it, the write was an
// unconditional os.WriteFile, so re-ingesting a source replaced its page
// wholesale and discarded everything accumulated since — including human edits
// (hyperguild#43: "re-ingest overwrites wholesale").
//
// The merge is deliberately asymmetric in the existing page's favour:
// wiki.Merge keeps the on-disk frontmatter and the on-disk version of any
// section both sides carry, adds sections only the extraction produced, and
// unions bullet lists. So a freshly extracted page can add to a note but never
// silently rewrite what a human put there — which is what makes it safe to run
// this on every ingest rather than behind a review gate.
//
// Known limitation: because the on-disk frontmatter wins, `last_updated` keeps
// its first-ingest date on a revised page. Left deliberately — the alternative
// is this function editing frontmatter it does not own, and no lint reads that
// field today.
func reviseOrCreate(dest string, page wiki.Page) (string, error) {
existing, err := os.ReadFile(dest)
switch {
case os.IsNotExist(err):
return page.Content, nil
case err != nil:
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
}
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
}
// mergeAll deduplicates pages by path, merging content from later occurrences. // mergeAll deduplicates pages by path, merging content from later occurrences.
func mergeAll(pages []wiki.Page) []wiki.Page { func mergeAll(pages []wiki.Page) []wiki.Page {
order := make([]string, 0, len(pages)) order := make([]string, 0, len(pages))
@@ -171,3 +171,125 @@ func mustJSON(v any) string {
} }
return string(b) return string(b)
} }
// --- B1: compile-on-ingest — revise the existing page, never clobber it -----
func TestRunRaw_MergesIntoAnExistingPageInsteadOfOverwritingIt(t *testing.T) {
// Before this, buildAndWrite did an unconditional os.WriteFile: re-ingesting
// a source replaced its page wholesale, discarding anything accumulated
// since — including human edits. hyperguild#43 recorded the symptom as
// "re-ingest overwrites wholesale".
dir := t.TempDir()
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
require.NoError(t, os.WriteFile(page, []byte(`---
title: 'Shape Up'
---
## Summary
A book about shaping product work.
## My Take
Read this before the next planning cycle.
`), 0o644))
_, err := RunRaw(dir, "shape-up", []RawPage{{
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
}}, false)
require.NoError(t, err)
got, err := os.ReadFile(page)
require.NoError(t, err)
s := string(got)
assert.Contains(t, s, "## My Take", "a section the extraction never saw must survive the merge")
assert.Contains(t, s, "Read this before the next planning cycle.")
assert.Contains(t, s, "Appetite beats estimation.", "newly extracted material must still land")
}
func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
// Compile-on-ingest runs on every ingest, so "nothing changed" must produce
// no diff at all. Otherwise every re-ingest dirties the tree, brain-sync
// commits it, and the git history fills with noise that hides real edits.
dir := t.TempDir()
raw := []RawPage{{
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
}}
_, err := RunRaw(dir, "shape-up", raw, false)
require.NoError(t, err)
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
require.NoError(t, err)
_, err = RunRaw(dir, "shape-up", raw, false)
require.NoError(t, err)
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
require.NoError(t, err)
assert.Equal(t, string(first), string(second),
"re-ingesting unchanged content must leave the page byte-identical")
}
// --- #105: source drift — an unchanged re-ingest must not re-extract --------
func TestRun_SkipsExtractionWhenTheSourceIsUnchanged(t *testing.T) {
// #103 made a re-ingest harmless (byte-identical merge). It is still not
// free: the full LLM extraction runs over every chunk to produce a result
// that merges to nothing. This is the cost half.
dir := t.TempDir()
var calls int
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
calls++
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
}}
content := "---\nsource: https://example.com/shape-up\n---\n\nThe body of the article.\n"
first, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
require.NoError(t, err)
require.Equal(t, 1, calls, "first ingest must extract")
assert.False(t, first.Skipped)
second, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
require.NoError(t, err)
assert.Equal(t, 1, calls, "unchanged content must not be extracted again")
assert.True(t, second.Skipped, "the skip must be reported, not silent")
}
func TestRun_ReExtractsWhenTheSourceBodyChanged(t *testing.T) {
dir := t.TempDir()
var calls int
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
calls++
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
}}
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion one\n", "shape-up", false)
require.NoError(t, err)
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion two — the source was edited\n", "shape-up", false)
require.NoError(t, err)
assert.Equal(t, 2, calls, "changed content must be re-extracted")
}
func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
// The hash covers the body, so re-fetching a page whose metadata moved
// (a re-crawl stamping a new date) is not treated as the source changing.
dir := t.TempDir()
var calls int
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
calls++
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
}}
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: monday\n---\n\nsame body\n", "shape-up", false)
require.NoError(t, err)
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: tuesday\n---\n\nsame body\n", "shape-up", false)
require.NoError(t, err)
assert.Equal(t, 1, calls, "only the body counts as the source")
}
+4 -4
View File
@@ -32,12 +32,12 @@ type Entry struct {
type Attempt struct { type Attempt struct {
Attempt int `json:"attempt"` Attempt int `json:"attempt"`
Model string `json:"model"` Model string `json:"model"`
Tier string `json:"tier"` // local | subagent | managed Tier string `json:"tier"` // local | subagent | managed
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
Verified bool `json:"verified"` Verified bool `json:"verified"`
Verdict string `json:"verdict,omitempty"` // accept | escalate | error Verdict string `json:"verdict,omitempty"` // accept | escalate | error
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
OutputSummary string `json:"output_summary,omitempty"` OutputSummary string `json:"output_summary,omitempty"`
RunnerOutput string `json:"runner_output,omitempty"` RunnerOutput string `json:"runner_output,omitempty"`
} }
@@ -18,6 +18,7 @@ func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
calls++ calls++
return "ready", nil return "ready", nil
}) })
if err != nil { if err != nil {
t.Fatalf("Do: %v", err) t.Fatalf("Do: %v", err)
} }
@@ -40,6 +41,7 @@ func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
} }
return "ready", nil return "ready", nil
}) })
if err != nil { if err != nil {
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err) t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
} }
+2 -3
View File
@@ -16,9 +16,8 @@ import (
// reachable database. // reachable database.
// //
// To run: // To run:
// // BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \ // go test ./internal/vectorstore/... -run Integration
// go test ./internal/vectorstore/... -run Integration
func dsn(t *testing.T) string { func dsn(t *testing.T) string {
t.Helper() t.Helper()
v := os.Getenv("BRAIN_PG_TEST_DSN") v := os.Getenv("BRAIN_PG_TEST_DSN")
+102 -4
View File
@@ -34,7 +34,7 @@ type section struct {
// all others keep a's version. Sections in b not present in a are appended. // all others keep a's version. Sections in b not present in a are appended.
func Merge(a, b Page) Page { func Merge(a, b Page) Page {
fmA, secsA := parseSections(a.Content) fmA, secsA := parseSections(a.Content)
_, secsB := parseSections(b.Content) fmB, secsB := parseSections(b.Content)
idx := make(map[string]int, len(secsA)) idx := make(map[string]int, len(secsA))
for i, s := range secsA { for i, s := range secsA {
@@ -53,11 +53,60 @@ func Merge(a, b Page) Page {
case bulletSections[sB.heading]: case bulletSections[sB.heading]:
secsA[i].content = mergeBullets(sA.content, sB.content) secsA[i].content = mergeBullets(sA.content, sB.content)
case appendSections[sB.heading]: case appendSections[sB.heading]:
secsA[i].content = strings.TrimRight(sA.content, "\n") + "\n\n" + strings.TrimLeft(sB.content, "\n") secsA[i].content = appendNew(sA.content, sB.content)
} }
} }
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)} return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), secsA)}
}
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
// on a page it is revising. Everything else on disk wins.
//
// #103 made the on-disk frontmatter win wholesale, which is what stops a
// regenerating writer from overwriting human-set metadata — a title someone
// fixed, or the claim-property envelope (brain#21). The cost was that fields
// the generator legitimately owns froze at their first-ingest values: when it
// last ran, and what it last saw.
//
// An explicit allowlist rather than a carve-out: the ownership boundary is
// nameable, and a field nobody has thought about stays human-owned by default,
// which is the safe direction.
var generatorOwned = map[string]bool{
"last_updated": true,
"source_hash": true,
}
// refreshOwned returns a's frontmatter with the generator-owned fields taken
// from b where b supplies them. Fields absent from b keep a's value, so a
// generator that stops emitting a field cannot blank it.
func refreshOwned(a, b string) string {
bVals := make(map[string]string)
for _, line := range strings.Split(b, "\n") {
key, value, found := strings.Cut(line, ":")
if !found || !generatorOwned[strings.TrimSpace(key)] {
continue
}
bVals[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
if len(bVals) == 0 {
return a
}
out := make([]string, 0, len(strings.Split(a, "\n")))
for _, line := range strings.Split(a, "\n") {
key, _, found := strings.Cut(line, ":")
k := strings.TrimSpace(key)
if found && generatorOwned[k] {
if v, ok := bVals[k]; ok {
out = append(out, k+": "+v)
delete(bVals, k)
continue
}
}
out = append(out, line)
}
return strings.Join(out, "\n")
} }
func parseSections(markdown string) (frontmatter string, sections []section) { func parseSections(markdown string) (frontmatter string, sections []section) {
@@ -96,15 +145,64 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
return return
} }
// rebuildContent serialises sections back into a page.
//
// Each section body is normalised to exactly one trailing newline and no
// leading blank line. Without that, a section keeps whatever blank lines it was
// parsed with and this function adds its own, so every merge pass grows the page
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
// merges on every run, so "merging changes nothing when nothing changed" has to
// hold at the byte level, not just the content level.
func rebuildContent(frontmatter string, sections []section) string { func rebuildContent(frontmatter string, sections []section) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(frontmatter) sb.WriteString(frontmatter)
for _, sec := range sections { for _, sec := range sections {
fmt.Fprintf(&sb, "\n## %s\n\n%s", sec.heading, sec.content) body := strings.Trim(sec.content, "\n")
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
} }
return sb.String() return sb.String()
} }
// appendNew concatenates the lines of b that a does not already carry.
//
// Append sections used to concatenate wholesale, which is correct exactly once.
// Compile-on-ingest merges a freshly extracted page into the one already on
// disk, so an unchanged source re-ingested would grow "Open Questions" and
// "Updates" on every run until the page is unreadable — and the drift guard
// that would otherwise catch it (hashing the source body) does not exist yet,
// so idempotency has to hold here rather than upstream.
//
// Line-level rather than block-level: the common case is a re-ingest carrying
// one genuinely new line alongside several it already has. A block-level
// "skip if identical" would duplicate the old lines every time that happened.
//
// The cost is that a line repeated verbatim on purpose collapses to one. For
// these sections — questions, updates, evolving notes — a repeat is noise
// rather than signal, and the failure is conservative: the page stops growing
// instead of growing wrongly.
func appendNew(a, b string) string {
seen := make(map[string]bool)
for _, line := range strings.Split(a, "\n") {
if t := strings.TrimSpace(line); t != "" {
seen[t] = true
}
}
var add []string
for _, line := range strings.Split(b, "\n") {
t := strings.TrimSpace(line)
if t == "" || seen[t] {
continue
}
seen[t] = true
add = append(add, line)
}
if len(add) == 0 {
return a // nothing new — leave the section byte-identical
}
return strings.TrimRight(a, "\n") + "\n\n" + strings.Join(add, "\n") + "\n"
}
func mergeBullets(a, b string) string { func mergeBullets(a, b string) string {
seen := make(map[string]bool) seen := make(map[string]bool)
var lines []string var lines []string
+90
View File
@@ -53,3 +53,93 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
assert.Contains(t, got.Content, "title: A") assert.Contains(t, got.Content, "title: A")
assert.NotContains(t, got.Content, "title: B") assert.NotContains(t, got.Content, "title: B")
} }
// --- brain#21/B1: merging must be safe to run again -------------------------
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
// Compile-on-ingest merges a freshly extracted page into the one already
// on disk. Bullet sections union, so they are naturally idempotent — but
// append sections concatenate, so re-ingesting an unchanged source would
// grow "Open Questions" on every run until the page is unreadable.
page := Page{Path: "wiki/sources/x.md", Content: `---
title: X
---
## Open Questions
- Does this converge?
`}
once := Merge(page, page)
twice := Merge(once, page)
assert.Equal(t, 1, strings.Count(once.Content, "Does this converge?"),
"merging a page with itself must not duplicate an append section")
assert.Equal(t, once.Content, twice.Content,
"merge must be idempotent — a third pass may not keep growing the page")
}
func TestMerge_AppendSectionStillAccumulatesGenuinelyNewContent(t *testing.T) {
// The dedup must not turn append sections into keep-a-only sections:
// new material is exactly what compile-on-ingest exists to add.
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: X
---
## Open Questions
- Does this converge?
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
title: X
---
## Open Questions
- What happens under load?
`}
got := Merge(existing, fresh).Content
assert.Contains(t, got, "Does this converge?", "existing question must survive")
assert.Contains(t, got, "What happens under load?", "new question must be added")
}
func TestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshed(t *testing.T) {
// #103 made on-disk frontmatter win wholesale, which is what protects
// human-set fields from a regenerating writer. The cost was that fields the
// generator legitimately owns — when it last ran, what it last saw — froze
// at their first-ingest values. An explicit owned-set is the boundary:
// named fields the generator may refresh, everything else human-owned.
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
last_updated: 2026-01-01
source_hash: aaaa
half_life: durable
claim_provenance: asserted
---
## Summary
Original.
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
title: 'Renamed by the model'
last_updated: 2026-08-22
source_hash: bbbb
---
## Summary
Rewritten by the model.
`}
got := Merge(existing, fresh).Content
assert.Contains(t, got, "last_updated: 2026-08-22", "generator owns last_updated")
assert.Contains(t, got, "source_hash: bbbb", "generator owns source_hash")
assert.Contains(t, got, "title: 'X'", "a human-set title must survive regeneration")
assert.Contains(t, got, "half_life: durable", "the claim envelope must survive regeneration")
assert.Contains(t, got, "claim_provenance: asserted")
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
assert.NotContains(t, got, "Renamed by the model")
}
+5 -5
View File
@@ -10,10 +10,10 @@ import (
"testing" "testing"
"time" "time"
"git.d-ma.be/mathias/hyperguild/internal/auth"
"github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt" "github.com/lestrrat-go/jwx/v2/jwt"
"git.d-ma.be/mathias/hyperguild/internal/auth"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -89,10 +89,10 @@ func TestValidator(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
tests := []struct { tests := []struct {
name string name string
token string token string
wantSub string wantSub string
wantErr bool wantErr bool
}{ }{
{ {
name: "valid jwt", name: "valid jwt",
+1 -1
View File
@@ -30,7 +30,7 @@ skills:
func writeModels(t *testing.T, content string) string { func writeModels(t *testing.T, content string) string {
t.Helper() t.Helper()
f := filepath.Join(t.TempDir(), "models.yaml") f := filepath.Join(t.TempDir(), "models.yaml")
require.NoError(t, os.WriteFile(f, []byte(content), 0o644)) require.NoError(t, os.WriteFile(f, []byte(content), 0644))
return f return f
} }
+8 -8
View File
@@ -9,14 +9,14 @@ import (
// RoutingConfig holds the runtime configuration for the routing pod. // RoutingConfig holds the runtime configuration for the routing pod.
// Separate from Config because the routing pod's surface differs from the supervisor's. // Separate from Config because the routing pod's surface differs from the supervisor's.
type RoutingConfig struct { type RoutingConfig struct {
Port string // ROUTING_PORT, default 3210 Port string // ROUTING_PORT, default 3210
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
LiteLLMAPIKey string // LITELLM_API_KEY LiteLLMAPIKey string // LITELLM_API_KEY
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300 BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log) BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual // RouteLocalFloor and RouteLocalCeil intentionally invert the usual
// floor < ceil mathematical convention: Floor (default 0.90) is the // floor < ceil mathematical convention: Floor (default 0.90) is the
// UPPER boundary — at/above it, always route local; Ceil (default 0.70) // UPPER boundary — at/above it, always route local; Ceil (default 0.70)
+3 -3
View File
@@ -16,9 +16,9 @@ import (
// Client calls an MCP server over Streamable HTTP / JSON-RPC. // Client calls an MCP server over Streamable HTTP / JSON-RPC.
type Client struct { type Client struct {
url string url string
token string token string
http *http.Client http *http.Client
} }
// ErrTokenRequired is returned by New when token is empty. Empty token // ErrTokenRequired is returned by New when token is empty. Empty token
-1
View File
@@ -22,7 +22,6 @@ func (s stubSkill) Tools() []registry.ToolDef {
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
}} }}
} }
func (s stubSkill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { func (s stubSkill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
return json.RawMessage(`{"ok":true}`), nil return json.RawMessage(`{"ok":true}`), nil
} }
+2 -2
View File
@@ -20,8 +20,8 @@ func TestFormatHistoryFormatsEntries(t *testing.T) {
entries := []session.Entry{ entries := []session.Entry{
{ {
Skill: "tdd", Phase: "red", FinalStatus: "pass", Skill: "tdd", Phase: "red", FinalStatus: "pass",
FilePath: "internal/foo/foo_test.go", FilePath: "internal/foo/foo_test.go",
Message: "wrote failing test for Foo", Message: "wrote failing test for Foo",
Timestamp: time.Now(), Timestamp: time.Now(),
}, },
} }
+4 -4
View File
@@ -32,12 +32,12 @@ type Entry struct {
type Attempt struct { type Attempt struct {
Attempt int `json:"attempt"` Attempt int `json:"attempt"`
Model string `json:"model"` Model string `json:"model"`
Tier string `json:"tier"` // local | subagent | managed Tier string `json:"tier"` // local | subagent | managed
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
Verified bool `json:"verified"` Verified bool `json:"verified"`
Verdict string `json:"verdict,omitempty"` // accept | escalate | error Verdict string `json:"verdict,omitempty"` // accept | escalate | error
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
OutputSummary string `json:"output_summary,omitempty"` OutputSummary string `json:"output_summary,omitempty"`
RunnerOutput string `json:"runner_output,omitempty"` RunnerOutput string `json:"runner_output,omitempty"`
} }
+18 -62
View File
@@ -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