Compare commits

..
Author SHA1 Message Date
mathiasandClaude Sonnet 5 09aeaa91f5 fix(wiki): give the preamble its own field instead of overloading heading==""
CI / Lint / Test / Vet (pull_request) Successful in 31s
CI / Mirror to GitHub (pull_request) Skipped
Second review pass on PR #103 found a third bug in the same file: the
preamble fix from the prior commit represented "content before the first
## heading" as a section with heading=="" — which collides with a literal
degenerate `## ` (marker, no title text; strings.TrimPrefix also yields
"").  Two entries sharing one key in Merge's idx map means one loses
lookup visibility. Reproduced: a has only a preamble, b introduces a
genuinely new degenerate `## ` section — Merge's idx wrongly reports it
as "already exists" (it's really a's unrelated preamble) and never adds
it, so b's section vanishes from the output entirely.

Fixed by giving parseSections a dedicated preamble return value instead
of folding it into the sections slice. It can no longer collide with
anything, degenerate or otherwise, because it's no longer keyed by
heading at all. Merge and rebuildContent updated to thread it through
directly. No other caller of parseSections/rebuildContent in this repo.

Also fixed a doc-comment left misplaced by the prior commit (appendNew's
comment had drifted onto canonicalLine, inserted between them).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:40:29 +02:00
mathiasandClaude Sonnet 5 9abe4369f2 fix(wiki): stop Merge from silently deleting on-disk content it can't parse
CI / Lint / Test / Vet (pull_request) Successful in 32s
CI / Mirror to GitHub (pull_request) Skipped
Review of PR #103 found a real data-loss bug: parseSections only captured
text between `## ` headings. Content before the first heading — an intro
paragraph, or, if the page had no headings at all, the entire body — was
dropped on the floor, not carried into any section. Merge then had
nothing of `a` to keep there, so the fresh extraction's sections silently
became the whole output wherever that content used to be. Exactly the
failure this file exists to prevent, just re-triggered by a page shape
the parser didn't expect instead of by every re-ingest.

Fixed by treating pre-first-heading content as a section with an empty
heading. Merge's switch never matches "", so it falls through to the
same on-disk-wins default every other unlisted section already gets —
no new merge path, just no longer discarding this one's input.

Second, separate finding from the same review: mergeBullets/appendNew
deduped on the raw trimmed line, so two re-extractions of the same
logical bullet with different internal whitespace (a real possibility —
the extractor is an LLM, not a fixed serializer) produced a duplicate
instead of a no-op, undermining the idempotency this file's own doc
comments claim. Fixed by comparing a whitespace-canonicalized key
(strings.Fields + single-space join) while still emitting the original
line's text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:34:11 +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
17 changed files with 425 additions and 437 deletions
+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:**
+27
View File
@@ -76,6 +76,33 @@ 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.
Known limitation: because the on-disk frontmatter wins, `last_updated` keeps its
first-ingest date on a revised page.
## 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']]"
``` ```
-5
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)
} }
+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")
}
+35 -1
View File
@@ -96,7 +96,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 +119,36 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
return Result{Pages: written, Warnings: warnings}, nil return Result{Pages: written, Warnings: warnings}, nil
} }
// 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,65 @@ 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")
}
+104 -13
View File
@@ -29,12 +29,16 @@ type section struct {
} }
// Merge combines two Page values with the same path. // Merge combines two Page values with the same path.
// Frontmatter is taken from a. Sections are merged by strategy: // Frontmatter is taken from a. The preamble (content before the first ##
// bullet sections union unique lines, append sections concatenate, // heading) is taken from a — same "on-disk wins" default every unlisted
// all others keep a's version. Sections in b not present in a are appended. // named section gets, kept as a separate field rather than a section with
// an empty heading so it can never collide with one (see parseSections).
// Sections are merged by strategy: bullet sections union unique lines,
// append sections concatenate, 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, preambleA, secsA := parseSections(a.Content)
_, secsB := parseSections(b.Content) _, _, 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,14 +57,24 @@ 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(fmA, preambleA, secsA)}
} }
func parseSections(markdown string) (frontmatter string, sections []section) { // parseSections splits markdown into its frontmatter, its preamble (content
// before the first `## ` heading), and its `## `-headed sections.
//
// The preamble is returned separately rather than as a section with an
// empty heading — a section keyed by heading=="" would collide with a
// literal degenerate `## ` (marker, no title text; strings.TrimPrefix also
// yields ""), which is a distinct, addressable section a page can genuinely
// have, not the same thing as "no heading yet." Two entries sharing one key
// in Merge's idx map means one of them silently loses lookup visibility —
// found in review as real, reproducible data loss, not a hypothetical.
func parseSections(markdown string) (frontmatter, preamble string, sections []section) {
lines := strings.Split(markdown, "\n") lines := strings.Split(markdown, "\n")
i := 0 i := 0
@@ -78,6 +92,22 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n")) frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
} }
// Everything before the first `## ` heading — an intro paragraph, or, if
// the page has no headings at all, the entire body — is ordinary content,
// not something this parser is entitled to drop (hyperguild#103 review:
// dropping it meant Merge, one caller up, would silently replace the
// whole on-disk body with the fresh extraction whenever a page didn't
// follow the ## convention — exactly the class of bug this file exists
// to prevent).
var preambleLines []string
for ; i < len(lines); i++ {
if strings.HasPrefix(lines[i], "## ") {
break
}
preambleLines = append(preambleLines, lines[i])
}
preamble = strings.Join(preambleLines, "\n")
var cur *section var cur *section
for ; i < len(lines); i++ { for ; i < len(lines); i++ {
line := lines[i] line := lines[i]
@@ -96,24 +126,85 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
return return
} }
func rebuildContent(frontmatter string, sections []section) string { // 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, preamble string, sections []section) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(frontmatter) sb.WriteString(frontmatter)
if body := strings.Trim(preamble, "\n"); body != "" {
fmt.Fprintf(&sb, "\n%s\n", body)
}
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()
} }
// canonicalLine collapses a line to its comparison key: internal whitespace
// runs (a re-extraction's bullet spacing, tabs vs spaces, trailing spaces)
// must not make the same logical line look new. strings.Fields already
// trims and splits on any whitespace run, so joining it back with single
// spaces is exactly "same words, same order, whitespace-insensitive."
func canonicalLine(line string) string {
return strings.Join(strings.Fields(line), " ")
}
// 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 c := canonicalLine(line); c != "" {
seen[c] = true
}
}
var add []string
for _, line := range strings.Split(b, "\n") {
c := canonicalLine(line)
if c == "" || seen[c] {
continue
}
seen[c] = 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
for _, line := range strings.Split(a+b, "\n") { for _, line := range strings.Split(a+b, "\n") {
trimmed := strings.TrimSpace(line) c := canonicalLine(line)
if trimmed == "" || seen[trimmed] { if c == "" || seen[c] {
continue continue
} }
seen[trimmed] = true seen[c] = true
lines = append(lines, line) lines = append(lines, line)
} }
return strings.Join(lines, "\n") + "\n" return strings.Join(lines, "\n") + "\n"
+121
View File
@@ -53,3 +53,124 @@ 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")
} }
// --- hyperguild#103 review: a page with content outside any `## ` section ---
// must never lose that content on merge. parseSections used to only capture
// text between `## ` headings — anything before the first heading (a common,
// ordinary shape for a hand-written note: an intro paragraph before the first
// section) or, worse, an entire page with no `## ` headings at all (a legacy
// note, a stub, any hand-edit that doesn't follow the section convention)
// vanished silently. Merge would then fill the page entirely from the fresh
// extraction — the opposite of "never silently rewrite what a human put
// there," the guarantee this whole file exists to provide.
func TestMerge_PreservesOnDiskContentWithNoSectionHeadings(t *testing.T) {
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nJust some free text a human wrote. No section headings at all.\n"}
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nFresh extraction's take.\n"}
got := Merge(a, b)
assert.Contains(t, got.Content, "Just some free text a human wrote",
"on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction")
}
func TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble(t *testing.T) {
// Second review pass on #103: representing the preamble as a section with
// heading=="" collides with a literal `## ` (marker, no title text) —
// strings.TrimPrefix("## ", "## ") is also "". `a` has only a preamble
// (no degenerate heading of its own); `b` introduces a genuinely new
// degenerate `## ` section. Because both key to "", Merge's idx thinks
// b's section "already exists" in a (it's really just a's unrelated
// preamble) and never adds it — b's section vanishes from the output
// entirely, not merged, not kept, not appended.
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph.\n\n## Definition\n\nA thing.\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## \n\nA genuinely new degenerate section's body.\n\n## Definition\n\nA thing.\n"}
got := Merge(a, b)
assert.Contains(t, got.Content, "An intro paragraph.", "a's own preamble must survive")
assert.Contains(t, got.Content, "A genuinely new degenerate section's body.",
"b's degenerate section is new content, not a collision with a's unrelated preamble — it must not be silently dropped")
}
func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) {
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"}
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"}
got := Merge(a, b)
assert.Contains(t, got.Content, "An intro paragraph before the first heading",
"text before the first ## heading is ordinary page content, not something the parser is entitled to drop")
}
// --- hyperguild#103 review: dedup must be whitespace-insensitive -----------
// Page content is produced by an LLM per run, so byte-identical re-extraction
// of "the same" logical line is not guaranteed — different bullet spacing or
// trailing whitespace on an otherwise-identical line must still dedup, or
// idempotency only holds when the extractor happens to reproduce whitespace
// exactly, which is not a real guarantee.
func TestMerge_AppendSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge?\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge? \n"}
got := Merge(a, b)
assert.Equal(t, 1, strings.Count(got.Content, "Does this converge?"),
"same logical line with different whitespace must dedup, not duplicate")
}
func TestMerge_BulletSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
got := Merge(a, b)
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"),
"same logical bullet with different whitespace must dedup, not duplicate")
}
// --- 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")
}
+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