Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b79f057e | ||
|
|
47bbac4b9f | ||
|
|
8a27ead91c | ||
|
|
1a9be0cfb1 | ||
|
|
dc2265da0b | ||
|
|
c176b71b3d | ||
|
|
4e8bdc137e | ||
|
|
42235dcb6a | ||
|
|
05330ad54a | ||
|
|
692244d213 | ||
|
|
d98a305985 | ||
|
|
670325e519 |
@@ -7,6 +7,29 @@
|
||||
"supervisor": {
|
||||
"url": "http://localhost:3200/mcp",
|
||||
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
||||
},
|
||||
"brain": {
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||
}
|
||||
},
|
||||
"gitea": {
|
||||
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||
}
|
||||
},
|
||||
"infra": {
|
||||
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
-19
@@ -53,14 +53,30 @@ These rules apply to every task across every project, regardless of harness.
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||
the tiered merge model in `assessor-loop`
|
||||
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||
what it sells instead of merging everything on green and claiming otherwise.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||
collisions between parallel sessions: two were not branching problems at all — a
|
||||
generated artifact written to the same path by both (branching turns that into a
|
||||
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||
timestamp generated artifacts. Do not reach for a branch.
|
||||
|
||||
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||
issues, review comments live on a thread separate from the issue's, and a
|
||||
merged-and-deleted branch cannot be amended (brain:
|
||||
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
@@ -78,6 +94,18 @@ These rules apply to every task across every project, regardless of harness.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||
Every gap, caveat, risk, open question and next step must land in one of four
|
||||
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||
remembered convention is an unreliable control.
|
||||
|
||||
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -148,10 +176,10 @@ Three machines on Tailscale:
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||
|
||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||
- **Orchestration**: k3s cluster across all three machines
|
||||
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||
- **Networking**: Tailscale mesh
|
||||
|
||||
## Project landscape
|
||||
@@ -247,10 +275,11 @@ When working on a project tagged with a client name:
|
||||
|
||||
## Harness-agnostic principles
|
||||
|
||||
This context is designed to work with any AI coding tool:
|
||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||
- Any tool that accepts a system prompt or reads a markdown context file
|
||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||
re-add adapters for them:
|
||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||
|
||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
@@ -262,15 +291,29 @@ Canonical sources of truth:
|
||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||
|
||||
Derived files (committed, regenerated by `task context:sync`):
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||
`.context/system-prompt.txt`
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||
|
||||
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||
you touch the repo, along with their entries in that repo's `task check`
|
||||
assertion.
|
||||
|
||||
Workflow:
|
||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||
derived together. Push.
|
||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||
up** from cwd and takes the first `AGENTS.md` it finds
|
||||
(user-level `~/.vibe/AGENTS.md`)
|
||||
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||
any harness, it is for pasting
|
||||
|
||||
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||
inside that repo, so this works — but by construction, not by luck.
|
||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||
is empty over the derived files (catches both modified-tracked drift
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
@@ -281,7 +324,20 @@ unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||
|
||||
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||
|
||||
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ jobs:
|
||||
set -e
|
||||
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||
IMAGE_TAG="${{ github.sha }}"
|
||||
# infra#311: github.sha is never empty in practice, but the manifest
|
||||
# patch below is the same shape that took Flux down estate-wide on
|
||||
# 2026-08-25 -- an empty tag yields `image: <repo>:`, which is invalid
|
||||
# YAML and fails the ENTIRE Kustomization, not just this app. The
|
||||
# assert costs nothing and removes the class.
|
||||
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||
chmod 600 ~/.ssh/infra_deploy_key
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit secret scan for the hyperguild repo.
|
||||
#
|
||||
# task check -> security:gitleaks enforces this in CI, but CI is after the
|
||||
# push. This catches a staged secret before it ever leaves the machine --
|
||||
# the infra#39 rotation pass found exactly this class of leak (a printed
|
||||
# credential, committed, archived into git) and there was no local gate to
|
||||
# have caught it. Mirrors infra's own .githooks/pre-commit (infra#326).
|
||||
#
|
||||
# Enable once per clone: git config core.hooksPath .githooks
|
||||
# Bypass if absolutely needed: git commit --no-verify
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GITLEAKS="$(command -v gitleaks || true)"
|
||||
if [[ -z "$GITLEAKS" && -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ]]; then
|
||||
GITLEAKS="$(go env GOPATH)/bin/gitleaks"
|
||||
fi
|
||||
|
||||
if [[ -z "$GITLEAKS" ]]; then
|
||||
echo "⚠ gitleaks not installed — skipping local secret scan (CI still enforces it)."
|
||||
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "→ Scanning staged changes for secrets..."
|
||||
if ! "$GITLEAKS" protect --staged --redact --config .gitleaks.toml >/dev/null 2>&1; then
|
||||
echo " ✗ gitleaks found a secret in the staged changes."
|
||||
echo " Review with: gitleaks protect --staged --redact --config .gitleaks.toml -v"
|
||||
echo " If it is a false positive, allowlist it in .gitleaks.toml or add a"
|
||||
echo " '# gitleaks:allow' comment. Do NOT --no-verify a real secret."
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ no staged secrets"
|
||||
+13
-5
@@ -56,8 +56,16 @@ paths = [
|
||||
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||
]
|
||||
|
||||
[[allowlists]]
|
||||
description = "Literal placeholder word REDACTED matched as if it were a token (verified 2026-08-04: extracted Secret == 'REDACTED' exactly, gitleaks' curl-auth-header rule matched the placeholder text itself, not a real credential)"
|
||||
condition = "AND"
|
||||
paths = ['''docs/superpowers/plans/2026-04-22-phase4-attempt-wiring\.md$''']
|
||||
regexes = ['''REDACTED''']
|
||||
# infra#344: the allowlist that used to sit here is gone, and so is the reason
|
||||
# for it. It suppressed docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
|
||||
# on the stated grounds that "extracted Secret == 'REDACTED' exactly ... the
|
||||
# placeholder text itself, not a real credential" (verified 2026-08-04). That was
|
||||
# not what the file contained: line 1054 carried a real 40-char hex Gitea PAT in
|
||||
# an `Authorization: token ...` header. Confirmed dead by side-effect — GET
|
||||
# /api/v1/user with it returns 401 — and redacted in that file, so there is now
|
||||
# nothing to allowlist.
|
||||
#
|
||||
# The `paths` entry also made the block far broader than its description: gitleaks
|
||||
# ORs global allowlist clauses and ignores `condition = "AND"`, so the whole file
|
||||
# was immune, not just lines matching /REDACTED/. A live token added to that doc
|
||||
# would have been suppressed on the strength of a note about a different value.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Historical gitleaks findings, dispositioned (infra#326 / infra#317).
|
||||
#
|
||||
# WHY THIS FILE AND NOT .gitleaks.toml:
|
||||
# This is a finding in git HISTORY, surfaced by the full-history audit
|
||||
# (`task security:gitleaks:history`). The working-tree scan that gates commits
|
||||
# (`task security:gitleaks`, the pre-commit hook, CI) is clean. Suppressing by
|
||||
# PATH in .gitleaks.toml would also blind the working-tree scan for that file
|
||||
# forever; a fingerprint pins one finding in one commit, so a NEW secret in the
|
||||
# same file still fires.
|
||||
#
|
||||
# History is not rewritten here, so the remediation for a real leak is
|
||||
# ROTATION -- making the committed value worthless -- never scrubbing.
|
||||
#
|
||||
# Dispositioned 2026-08-29. Re-check with: task security:gitleaks:history
|
||||
|
||||
# --- Real credential, ALREADY DEAD -------------------------------------------
|
||||
# A Gitea PAT pasted into a CD-watching command in a planning doc
|
||||
# (`Authorization: token <40-hex>`, last_eight 3105a670).
|
||||
#
|
||||
# Verified dead 2026-08-29: authenticating against
|
||||
# GET https://git.d-ma.be/api/v1/user with this exact value returns 401, so the
|
||||
# committed token no longer grants anything. It was almost certainly invalidated
|
||||
# by the early-August rotation of the `mathias` PATs (the same rotation whose
|
||||
# fan-out caused infra#260, where CI copies of the old value kept failing).
|
||||
#
|
||||
# No rotation action outstanding. Kept as a fingerprint rather than removed from
|
||||
# history because history cannot be rewritten here.
|
||||
#
|
||||
# The generalisable lesson is recorded separately: a credential printed into a
|
||||
# doc/transcript is permanent, so the only remedy is rotating it -- which in
|
||||
# this case had already happened for unrelated reasons, by luck rather than by
|
||||
# the leak being noticed.
|
||||
c9310b1079478bf4430b0a2dfb25445d7ccc9b59:docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md:curl-auth-header:1053
|
||||
@@ -48,14 +48,30 @@ These rules apply to every task across every project, regardless of harness.
|
||||
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||
deployable. Never create long-lived feature branches.
|
||||
|
||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
||||
actively working on the same repo simultaneously, create a short-lived branch
|
||||
(`agent/<description>`), finish the task, and merge to main within the same
|
||||
session. Do not leave agent branches open between sessions.
|
||||
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||
the tiered merge model in `assessor-loop`
|
||||
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
|
||||
what it sells instead of merging everything on green and claiming otherwise.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use
|
||||
PR flow only when a human reviewer outside the project is required. Document
|
||||
the reason in PROJECT.md.
|
||||
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||
collisions between parallel sessions: two were not branching problems at all — a
|
||||
generated artifact written to the same path by both (branching turns that into a
|
||||
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
|
||||
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
|
||||
push resolved by rebase in seconds. Use a worktree per session on shared repos and
|
||||
timestamp generated artifacts. Do not reach for a branch.
|
||||
|
||||
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
|
||||
issues, review comments live on a thread separate from the issue's, and a
|
||||
merged-and-deleted branch cannot be amended (brain:
|
||||
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
|
||||
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
|
||||
|
||||
**Exception — external contributor or client four-eyes requirement:** Use PR flow
|
||||
when a human reviewer outside the project is required. Document the reason in
|
||||
PROJECT.md.
|
||||
|
||||
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||
@@ -73,6 +89,18 @@ These rules apply to every task across every project, regardless of harness.
|
||||
- Surface the brain entries and issue numbers in the closing summary so the
|
||||
trail is auditable.
|
||||
|
||||
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||
Every gap, caveat, risk, open question and next step must land in one of four
|
||||
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||
remembered convention is an unreliable control.
|
||||
|
||||
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||
saying in a summary, it is worth writing where it can be found again without you.
|
||||
|
||||
## CAD sprint patterns
|
||||
|
||||
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||
@@ -143,10 +171,10 @@ Three machines on Tailscale:
|
||||
|---------|------|-----------|
|
||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||
| iguana | Services, builds | M2 Ultra Mac |
|
||||
| flamingo | Daily driver, edge | Mac mini, ~/dev is here |
|
||||
| flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
|
||||
|
||||
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
|
||||
- **Orchestration**: k3s cluster across all three machines
|
||||
- **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
|
||||
- **Networking**: Tailscale mesh
|
||||
|
||||
## Project landscape
|
||||
@@ -242,10 +270,11 @@ When working on a project tagged with a client name:
|
||||
|
||||
## Harness-agnostic principles
|
||||
|
||||
This context is designed to work with any AI coding tool:
|
||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
||||
- Any tool that accepts a system prompt or reads a markdown context file
|
||||
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||
re-add adapters for them:
|
||||
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||
- Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
|
||||
- Any other tool that reads `AGENTS.md`, which is the de facto convention
|
||||
|
||||
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||
@@ -257,15 +286,29 @@ Canonical sources of truth:
|
||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||
|
||||
Derived files (committed, regenerated by `task context:sync`):
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
||||
`.context/system-prompt.txt`
|
||||
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||
|
||||
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||
you touch the repo, along with their entries in that repo's `task check`
|
||||
assertion.
|
||||
|
||||
Workflow:
|
||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||
derived together. Push.
|
||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
||||
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||
- `CLAUDE.md` — Claude Code (walks up the tree)
|
||||
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
|
||||
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
|
||||
up** from cwd and takes the first `AGENTS.md` it finds
|
||||
(user-level `~/.vibe/AGENTS.md`)
|
||||
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
|
||||
any harness, it is for pasting
|
||||
|
||||
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
|
||||
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
|
||||
inside that repo, so this works — but by construction, not by luck.
|
||||
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||
is empty over the derived files (catches both modified-tracked drift
|
||||
and missing-untracked adapters). A drift fails the check with a
|
||||
@@ -276,7 +319,20 @@ unconditionally on every host, every harness.
|
||||
|
||||
## Engineering Skills
|
||||
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list.
|
||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
|
||||
|
||||
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
|
||||
|
||||
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
|
||||
|
||||
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
|
||||
|
||||
**Skill trigger table — load before starting, not after getting stuck:**
|
||||
|
||||
|
||||
@@ -76,46 +76,6 @@ that prompted the question is answered by naming it here instead.
|
||||
Full reasoning, including the reversal trigger: brain
|
||||
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
||||
|
||||
## 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)
|
||||
|
||||
| Tool | What it does |
|
||||
|
||||
+29
-10
@@ -86,18 +86,37 @@ tasks:
|
||||
check:
|
||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||
cmds:
|
||||
- task: context:sync
|
||||
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
||||
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
||||
# the tree for the canonical .context/AGENT.md, which lives in local-dev
|
||||
# and does not exist on a CI runner. Without this guard, the script
|
||||
# silently falls back to "project context only" and regenerates AGENTS.md
|
||||
# and .context/system-prompt.txt shorter than their committed, correct
|
||||
# versions -- so the drift assertion fails every CI run for a purely
|
||||
# environmental reason, not real drift. Verified, not assumed: this is
|
||||
# exactly what was happening here (AGENTS.md 430 lines committed vs. an
|
||||
# 82-line regeneration on the runner).
|
||||
#
|
||||
# The cost is real and worth stating: drift is therefore only ever caught
|
||||
# on a developer machine, or by infra's estate-wide context-sync-drift
|
||||
# CronJob (ADR-0019), which does have canonical access and is the actual
|
||||
# enforcement mechanism now.
|
||||
- cmd: |
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||
if [ -n "$drift" ]; then
|
||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||
echo "$drift" >&2
|
||||
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
|
||||
if [ -n "${CI:-}" ]; then
|
||||
echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
|
||||
else
|
||||
bash scripts/context-sync.sh
|
||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||
if [ -n "$drift" ]; then
|
||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||
echo "$drift" >&2
|
||||
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
|
||||
echo "✓ context: canonical and adapters are in sync"
|
||||
- task: lint
|
||||
- task: test
|
||||
- task: vet
|
||||
|
||||
@@ -1051,7 +1051,7 @@ git push
|
||||
```bash
|
||||
# Poll until complete (replace RUN_ID with the new run):
|
||||
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
||||
-H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \
|
||||
-H "Authorization: token <REDACTED-dead-gitea-pat-infra#344>" \
|
||||
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
||||
```
|
||||
|
||||
|
||||
@@ -236,6 +236,11 @@ func main() {
|
||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||
}
|
||||
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
|
||||
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
|
||||
tertiaryKey := os.Getenv("GEMINI_API_KEY")
|
||||
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
|
||||
}
|
||||
answerComplete = router.Complete
|
||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||
}
|
||||
@@ -391,15 +396,39 @@ func main() {
|
||||
}
|
||||
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
||||
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
||||
|
||||
// Project directories excluded from ingestion entirely, as globs
|
||||
// against the project directory name — e.g.
|
||||
// "*cobalt-dingo*,*claude-worktrees-bridge-cse*".
|
||||
//
|
||||
// This is a veto list, deliberately named as one. It complements the
|
||||
// client-name scrubber rather than replacing it: the scrubber drops
|
||||
// individual TURNS whose content matches, while this never opens a
|
||||
// project's files at all. Sessions working on live company financials
|
||||
// are excluded here because their content is confidential in ways no
|
||||
// pattern reliably recognises, and their learnings are captured
|
||||
// deliberately through the session-close ritual instead.
|
||||
//
|
||||
// Caveat worth knowing: a project directory is derived from the
|
||||
// session's WORKING DIRECTORY, not from the repositories it touches.
|
||||
// A session run from ~/dev that happens to read a skipped repo will
|
||||
// NOT be excluded by this. The scrubber remains the control for
|
||||
// content; this is a control for whole workspaces.
|
||||
skipProjects := splitList(os.Getenv("CLAUDE_INGEST_SKIP_PROJECTS"))
|
||||
if len(skipProjects) > 0 {
|
||||
logger.Info("claudewatcher: projects excluded from ingestion", "patterns", skipProjects)
|
||||
}
|
||||
|
||||
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
||||
go func() {
|
||||
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
||||
SessionsDir: claudeDir,
|
||||
Host: host,
|
||||
Interval: interval,
|
||||
Sink: sink,
|
||||
Cursors: cursorStore,
|
||||
Logger: logger,
|
||||
SessionsDir: claudeDir,
|
||||
Host: host,
|
||||
Interval: interval,
|
||||
Sink: sink,
|
||||
Cursors: cursorStore,
|
||||
Logger: logger,
|
||||
SkipProjects: skipProjects,
|
||||
}); err != nil && err != context.Canceled {
|
||||
logger.Error("claudewatcher exited", "err", err)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ type FileBuffer struct {
|
||||
}
|
||||
|
||||
type bufferLine struct {
|
||||
ID string `json:"id"`
|
||||
Entry capture.AuditEntry `json:"entry"`
|
||||
ID string `json:"id"`
|
||||
Entry capture.AuditEntry `json:"entry"`
|
||||
}
|
||||
|
||||
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
||||
|
||||
@@ -46,10 +46,10 @@ func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
|
||||
// unwritableBuffer always reports it cannot be written (floor condition).
|
||||
type unwritableBuffer struct{}
|
||||
|
||||
func (unwritableBuffer) Writable() 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) Confirm(string) error { return nil }
|
||||
func (unwritableBuffer) Writable() 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) Confirm(string) error { return nil }
|
||||
|
||||
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
||||
t.Helper()
|
||||
|
||||
@@ -38,9 +38,11 @@ func (f fakeTracker) CreateIssue(context.Context, string, string, string) (captu
|
||||
}
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
|
||||
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
@@ -183,6 +185,7 @@ type refusingAudit struct{}
|
||||
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||
return 0, errors.New("central audit sink unreachable")
|
||||
}
|
||||
|
||||
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package claudewatcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// recordingHandler captures log records so a test can assert that a file was
|
||||
// never even attempted — the absence of a read error is the evidence.
|
||||
type recordingHandler struct {
|
||||
mu sync.Mutex
|
||||
records []string
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
|
||||
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
var sb strings.Builder
|
||||
sb.WriteString(r.Message)
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
sb.WriteString(" " + a.Key + "=" + a.Value.String())
|
||||
return true
|
||||
})
|
||||
h.records = append(h.records, sb.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
func (h *recordingHandler) contains(substr string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, r := range h.records {
|
||||
if strings.Contains(r, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func session(t *testing.T, root, project, id string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, project)
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
return writeSession(t, dir, id, []string{
|
||||
`{"type":"user","sessionId":"` + id + `","message":"a prompt"}`,
|
||||
`{"type":"assistant","sessionId":"` + id + `","message":{"content":[{"type":"text","text":"an answer"}]}}`,
|
||||
})
|
||||
}
|
||||
|
||||
// A named project is not ingested, and its neighbours still are.
|
||||
func TestSkipProjects_skipsTheNamedProjectOnly(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "skipped")
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"-home-mathias-dev-AGENTS-cobalt-dingo"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
assert.Equal(t, "-home-mathias-dev", sink.batches[0].ProjectID)
|
||||
}
|
||||
|
||||
// Globs are necessary, not a convenience. A project directory is derived from
|
||||
// the session's WORKING DIRECTORY, so a git worktree produces an ephemeral
|
||||
// name like "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC...". An
|
||||
// exact-match list could never keep up with those.
|
||||
func TestSkipProjects_globMatchesEphemeralWorktreeNames(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev--claude-worktrees-bridge-cse-017xoCEcrc35", "worktree")
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"*claude-worktrees-bridge-cse*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
}
|
||||
|
||||
// The point of skipping at the directory walk: the file is never OPENED, so
|
||||
// its content never enters this process. Proven by making it unreadable — a
|
||||
// skipped file produces no read error because nothing tried to read it, while
|
||||
// the same file in a non-skipped project does.
|
||||
func TestSkipProjects_theFileIsNeverOpened(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||
require.NoError(t, os.Chmod(path, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||
|
||||
rec := &recordingHandler{}
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
Logger: slog.New(rec),
|
||||
SkipProjects: []string{"*cobalt-dingo*"},
|
||||
}))
|
||||
|
||||
assert.Empty(t, sink.batches)
|
||||
assert.False(t, rec.contains("file failed"),
|
||||
"a skipped file must not even be attempted")
|
||||
}
|
||||
|
||||
// The control test for the one above: without the skip, the unreadable file IS
|
||||
// attempted and fails. Otherwise the assertion above could pass for the wrong
|
||||
// reason.
|
||||
func TestSkipProjects_withoutTheSkipTheSameFileIsAttempted(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
path := session(t, tmp, "-home-mathias-dev-AGENTS-cobalt-dingo", "unreadable")
|
||||
require.NoError(t, os.Chmod(path, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(path, 0o644) })
|
||||
|
||||
rec := &recordingHandler{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: &memSink{},
|
||||
Logger: slog.New(rec),
|
||||
}))
|
||||
|
||||
assert.True(t, rec.contains("file failed"),
|
||||
"the unreadable file must fail when not skipped — otherwise the skip test proves nothing")
|
||||
}
|
||||
|
||||
// Patterns match the project directory NAME, not the whole path. A temp
|
||||
// directory or a home directory component must not accidentally match and
|
||||
// silently disable ingestion.
|
||||
func TestSkipProjects_matchesTheProjectNameNotTheFullPath(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
// tmp is somewhere under /tmp; this must not match the project name.
|
||||
SkipProjects: []string{"*tmp*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1, "a path component outside the project name must not match")
|
||||
}
|
||||
|
||||
// An unparseable pattern must stop the watcher rather than be ignored. A
|
||||
// silently dropped skip rule means ingesting what was meant to be excluded,
|
||||
// which is the failure that cannot be noticed after the fact.
|
||||
func TestSkipProjects_invalidPatternRefusesToRun(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
err := TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"[unclosed"},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "[unclosed")
|
||||
assert.Empty(t, sink.batches, "nothing may be ingested while a skip rule is broken")
|
||||
}
|
||||
|
||||
func TestSkipProjects_emptyListChangesNothing(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp, Host: "koala", Sink: sink, SkipProjects: nil,
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1)
|
||||
}
|
||||
|
||||
// The difference between SkipDir and filtering after the walk, made
|
||||
// observable: an unreadable skipped directory must not disturb anything.
|
||||
//
|
||||
// Without SkipDir, WalkDir reads the directory's entries, gets a permission
|
||||
// error, and that error aborts the ENTIRE walk — so one skipped project with
|
||||
// awkward permissions would silently stop ingesting every other project.
|
||||
// Returning SkipDir means its contents are never listed at all.
|
||||
//
|
||||
// Added after a mutation test showed the earlier assertions could not tell the
|
||||
// two implementations apart.
|
||||
func TestSkipProjects_anUnreadableSkippedDirDoesNotBreakTheWalk(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
session(t, tmp, "-home-mathias-dev", "kept")
|
||||
|
||||
blocked := filepath.Join(tmp, "-home-mathias-dev-AGENTS-cobalt-dingo")
|
||||
require.NoError(t, os.MkdirAll(blocked, 0o755))
|
||||
_ = writeSession(t, blocked, "secret", []string{
|
||||
`{"type":"user","sessionId":"secret","message":"should never be read"}`,
|
||||
})
|
||||
require.NoError(t, os.Chmod(blocked, 0o000))
|
||||
t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) })
|
||||
|
||||
sink := &memSink{}
|
||||
require.NoError(t, TickOnce(context.Background(), Config{
|
||||
SessionsDir: tmp,
|
||||
Host: "koala",
|
||||
Sink: sink,
|
||||
SkipProjects: []string{"*cobalt-dingo*"},
|
||||
}))
|
||||
|
||||
require.Len(t, sink.batches, 1, "an unreadable skipped directory must not abort the walk")
|
||||
assert.Equal(t, "kept", sink.batches[0].SessionID)
|
||||
}
|
||||
@@ -45,6 +45,25 @@ type Config struct {
|
||||
Sink Sink
|
||||
Cursors *CursorStore
|
||||
Logger *slog.Logger
|
||||
|
||||
// SkipProjects excludes whole project directories from ingestion, as
|
||||
// filepath.Match globs tested against the project directory NAME (the
|
||||
// basename, e.g. "-home-mathias-dev"), not the full path.
|
||||
//
|
||||
// Globs rather than exact names because a project directory is derived
|
||||
// from the session's working directory, so a git worktree yields an
|
||||
// ephemeral name like
|
||||
// "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC..." that no
|
||||
// exact-match list could keep up with.
|
||||
//
|
||||
// Matching directories are skipped during the directory WALK, so their
|
||||
// files are never opened and their content never enters this process —
|
||||
// which is a stronger property than filtering it out afterwards.
|
||||
//
|
||||
// An unparseable pattern stops the watcher. A silently dropped skip rule
|
||||
// means ingesting what was meant to be excluded, and that is not
|
||||
// noticeable after the fact.
|
||||
SkipProjects []string
|
||||
}
|
||||
|
||||
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
||||
@@ -68,6 +87,9 @@ func Watch(ctx context.Context, cfg Config) error {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Logger.Info("claudewatcher: started",
|
||||
"sessions_dir", cfg.SessionsDir,
|
||||
"host", cfg.Host,
|
||||
@@ -91,7 +113,7 @@ func Watch(ctx context.Context, cfg Config) error {
|
||||
// runTick is one polling pass. Exposed (lowercase) for tests via
|
||||
// TickOnce.
|
||||
func runTick(ctx context.Context, cfg Config) {
|
||||
files, err := listSessionFiles(cfg.SessionsDir)
|
||||
files, err := listSessionFiles(cfg.SessionsDir, cfg.SkipProjects, cfg.Logger)
|
||||
if err != nil {
|
||||
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
||||
return
|
||||
@@ -119,17 +141,52 @@ func TickOnce(ctx context.Context, cfg Config) error {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = slog.Default()
|
||||
}
|
||||
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||
return err
|
||||
}
|
||||
runTick(ctx, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func listSessionFiles(root string) ([]string, error) {
|
||||
// validateSkipPatterns rejects a pattern filepath.Match cannot parse.
|
||||
//
|
||||
// Checked up front rather than per file: a bad pattern that only failed at
|
||||
// match time would be reported once per session file per tick, and — worse —
|
||||
// filepath.Match returns an error alongside a false match, so the directory
|
||||
// would be ingested while the log complained.
|
||||
func validateSkipPatterns(patterns []string) error {
|
||||
for _, p := range patterns {
|
||||
if _, err := filepath.Match(p, "probe"); err != nil {
|
||||
return fmt.Errorf("invalid skip-project pattern %q: %w", p, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipProject reports whether a project directory name matches any pattern.
|
||||
func skipProject(name string, patterns []string) bool {
|
||||
for _, p := range patterns {
|
||||
// The error case is already excluded by validateSkipPatterns.
|
||||
if ok, _ := filepath.Match(p, name); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func listSessionFiles(root string, skip []string, log *slog.Logger) ([]string, error) {
|
||||
var out []string
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
// SkipDir, so nothing inside is even listed. The files are never
|
||||
// opened and their content never reaches this process.
|
||||
if path != root && skipProject(d.Name(), skip) {
|
||||
log.Info("claudewatcher: project skipped by configuration", "project", d.Name())
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".jsonl") {
|
||||
|
||||
@@ -5,14 +5,18 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Router calls Primary first; on any error falls back to Fallback.
|
||||
// Fallback may be nil, in which case primary errors are returned directly.
|
||||
// Router calls Primary first; on any error falls back to Fallback, then to
|
||||
// Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
|
||||
// the chain returns the errors accumulated so far.
|
||||
type Router struct {
|
||||
Primary *Client
|
||||
Fallback *Client
|
||||
Tertiary *Client
|
||||
}
|
||||
|
||||
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
||||
// Complete implements pipeline.CompleteFunc, routing through Primary, then
|
||||
// Fallback, then Tertiary — trying each configured tier in order and
|
||||
// returning the first success.
|
||||
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
out, err := r.Primary.Complete(ctx, system, user)
|
||||
if err == nil {
|
||||
@@ -22,8 +26,15 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
|
||||
return "", fmt.Errorf("primary llm: %w", err)
|
||||
}
|
||||
out, err2 := r.Fallback.Complete(ctx, system, user)
|
||||
if err2 != nil {
|
||||
if err2 == nil {
|
||||
return out, nil
|
||||
}
|
||||
if r.Tertiary == nil {
|
||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
||||
}
|
||||
out, err3 := r.Tertiary.Complete(ctx, system, user)
|
||||
if err3 != nil {
|
||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v; tertiary llm: %v", err, err2, err3)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -69,3 +69,78 @@ func TestRouter_NilFallback(t *testing.T) {
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer fail.Close()
|
||||
tertiary := mockServer(t, "from-tertiary")
|
||||
defer tertiary.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-tertiary", out)
|
||||
}
|
||||
|
||||
func TestRouter_TertiaryNotCalledWhenFallbackSucceeds(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer fail.Close()
|
||||
fallback := mockServer(t, "from-fallback")
|
||||
defer fallback.Close()
|
||||
tertiary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("tertiary must not be called when fallback succeeds")
|
||||
}))
|
||||
defer tertiary.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-fallback", out)
|
||||
}
|
||||
|
||||
func TestRouter_AllThreeFail(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
Tertiary: New(fail.URL, "", "m", time.Second),
|
||||
}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "primary llm")
|
||||
assert.Contains(t, err.Error(), "fallback llm")
|
||||
assert.Contains(t, err.Error(), "tertiary llm")
|
||||
}
|
||||
|
||||
func TestRouter_NilTertiary(t *testing.T) {
|
||||
// Existing 2-tier deployments (no BRAIN_LLM_TERTIARY_URL) must keep working
|
||||
// exactly as before — Tertiary is opt-in, not required.
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "tertiary")
|
||||
}
|
||||
|
||||
@@ -134,9 +134,11 @@ type stubTracker struct{}
|
||||
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ type capFakeTracker struct{}
|
||||
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||
}
|
||||
|
||||
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||
return capture.IssueRef{}, nil
|
||||
}
|
||||
|
||||
@@ -76,9 +76,6 @@ func buildFrontmatter(rp RawPage, date string) string {
|
||||
}
|
||||
fmt.Fprintf(&sb, "date_ingested: %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 != "" {
|
||||
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
||||
}
|
||||
|
||||
@@ -20,13 +20,9 @@ type RawPage struct {
|
||||
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
||||
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
||||
// might emit. Only meaningful for Type == "source".
|
||||
// SourceHash is sha256 of the ingested BODY (frontmatter excluded), stamped
|
||||
// so the next ingest of the same source can tell "unchanged" from "edited"
|
||||
// without re-running extraction (#105).
|
||||
SourceHash string `json:"-"`
|
||||
Source string `json:"-"`
|
||||
Author string `json:"-"`
|
||||
Published string `json:"-"`
|
||||
Source string `json:"-"`
|
||||
Author string `json:"-"`
|
||||
Published string `json:"-"`
|
||||
}
|
||||
|
||||
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
||||
@@ -116,11 +112,6 @@ type sourceMeta struct {
|
||||
Source string
|
||||
Author 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
|
||||
@@ -163,7 +154,6 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) {
|
||||
if pages[i].Type != "source" {
|
||||
continue
|
||||
}
|
||||
pages[i].SourceHash = meta.BodyHash
|
||||
pages[i].Source = meta.Source
|
||||
pages[i].Author = meta.Author
|
||||
pages[i].Published = meta.Published
|
||||
|
||||
@@ -3,8 +3,6 @@ package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -28,11 +26,6 @@ type Config struct {
|
||||
type Result struct {
|
||||
Pages []string // relative paths written (or would-be written in dry-run)
|
||||
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/.
|
||||
@@ -50,18 +43,6 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
|
||||
sourceSlug := wiki.Slug(source)
|
||||
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)
|
||||
|
||||
var allRaw []RawPage
|
||||
@@ -78,9 +59,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
allWarnings = append(allWarnings, warnings...)
|
||||
}
|
||||
|
||||
meta := parseContentFrontmatter(content)
|
||||
meta.BodyHash = bodyHash
|
||||
applySourceMeta(allRaw, meta)
|
||||
applySourceMeta(allRaw, parseContentFrontmatter(content))
|
||||
|
||||
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||
}
|
||||
@@ -117,11 +96,7 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||
}
|
||||
content, err := reviseOrCreate(dest, page)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
|
||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||
}
|
||||
}
|
||||
@@ -140,75 +115,6 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
||||
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.
|
||||
func mergeAll(pages []wiki.Page) []wiki.Page {
|
||||
order := make([]string, 0, len(pages))
|
||||
|
||||
@@ -171,125 +171,3 @@ func mustJSON(v any) string {
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ type Entry struct {
|
||||
type Attempt struct {
|
||||
Attempt int `json:"attempt"`
|
||||
Model string `json:"model"`
|
||||
Tier string `json:"tier"` // local | subagent | managed
|
||||
Tier string `json:"tier"` // local | subagent | managed
|
||||
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"`
|
||||
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
||||
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
||||
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
||||
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
||||
OutputSummary string `json:"output_summary,omitempty"`
|
||||
RunnerOutput string `json:"runner_output,omitempty"`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
|
||||
calls++
|
||||
return "ready", nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
@@ -41,7 +40,6 @@ func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
|
||||
}
|
||||
return "ready", nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ import (
|
||||
// reachable database.
|
||||
//
|
||||
// To run:
|
||||
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
||||
// go test ./internal/vectorstore/... -run Integration
|
||||
//
|
||||
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
||||
// go test ./internal/vectorstore/... -run Integration
|
||||
func dsn(t *testing.T) string {
|
||||
t.Helper()
|
||||
v := os.Getenv("BRAIN_PG_TEST_DSN")
|
||||
|
||||
@@ -34,7 +34,7 @@ type section struct {
|
||||
// all others keep a's version. Sections in b not present in a are appended.
|
||||
func Merge(a, b Page) Page {
|
||||
fmA, secsA := parseSections(a.Content)
|
||||
fmB, secsB := parseSections(b.Content)
|
||||
_, secsB := parseSections(b.Content)
|
||||
|
||||
idx := make(map[string]int, len(secsA))
|
||||
for i, s := range secsA {
|
||||
@@ -53,60 +53,11 @@ func Merge(a, b Page) Page {
|
||||
case bulletSections[sB.heading]:
|
||||
secsA[i].content = mergeBullets(sA.content, sB.content)
|
||||
case appendSections[sB.heading]:
|
||||
secsA[i].content = appendNew(sA.content, sB.content)
|
||||
secsA[i].content = strings.TrimRight(sA.content, "\n") + "\n\n" + strings.TrimLeft(sB.content, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)}
|
||||
}
|
||||
|
||||
func parseSections(markdown string) (frontmatter string, sections []section) {
|
||||
@@ -145,64 +96,15 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
|
||||
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 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(frontmatter)
|
||||
for _, sec := range sections {
|
||||
body := strings.Trim(sec.content, "\n")
|
||||
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
|
||||
fmt.Fprintf(&sb, "\n## %s\n\n%s", sec.heading, sec.content)
|
||||
}
|
||||
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 {
|
||||
seen := make(map[string]bool)
|
||||
var lines []string
|
||||
|
||||
@@ -53,93 +53,3 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
|
||||
assert.Contains(t, got.Content, "title: A")
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -89,10 +89,10 @@ func TestValidator(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
wantSub string
|
||||
wantErr bool
|
||||
name string
|
||||
token string
|
||||
wantSub string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid jwt",
|
||||
|
||||
@@ -30,7 +30,7 @@ skills:
|
||||
func writeModels(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "models.yaml")
|
||||
require.NoError(t, os.WriteFile(f, []byte(content), 0644))
|
||||
require.NoError(t, os.WriteFile(f, []byte(content), 0o644))
|
||||
return f
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
// RoutingConfig holds the runtime configuration for the routing pod.
|
||||
// Separate from Config because the routing pod's surface differs from the supervisor's.
|
||||
type RoutingConfig struct {
|
||||
Port string // ROUTING_PORT, default 3210
|
||||
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
|
||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
||||
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
|
||||
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
||||
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
||||
Port string // ROUTING_PORT, default 3210
|
||||
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
|
||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
||||
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
|
||||
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
||||
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
||||
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
||||
// floor < ceil mathematical convention: Floor (default 0.90) is the
|
||||
// UPPER boundary — at/above it, always route local; Ceil (default 0.70)
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
|
||||
// Client calls an MCP server over Streamable HTTP / JSON-RPC.
|
||||
type Client struct {
|
||||
url string
|
||||
token string
|
||||
http *http.Client
|
||||
url string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// ErrTokenRequired is returned by New when token is empty. Empty token
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s stubSkill) Tools() []registry.ToolDef {
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
}}
|
||||
}
|
||||
|
||||
func (s stubSkill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestFormatHistoryFormatsEntries(t *testing.T) {
|
||||
entries := []session.Entry{
|
||||
{
|
||||
Skill: "tdd", Phase: "red", FinalStatus: "pass",
|
||||
FilePath: "internal/foo/foo_test.go",
|
||||
Message: "wrote failing test for Foo",
|
||||
FilePath: "internal/foo/foo_test.go",
|
||||
Message: "wrote failing test for Foo",
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ type Entry struct {
|
||||
type Attempt struct {
|
||||
Attempt int `json:"attempt"`
|
||||
Model string `json:"model"`
|
||||
Tier string `json:"tier"` // local | subagent | managed
|
||||
Tier string `json:"tier"` // local | subagent | managed
|
||||
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"`
|
||||
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
||||
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
||||
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
||||
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
||||
OutputSummary string `json:"output_summary,omitempty"`
|
||||
RunnerOutput string `json:"runner_output,omitempty"`
|
||||
}
|
||||
|
||||
+62
-18
@@ -5,15 +5,29 @@
|
||||
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
||||
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
||||
#
|
||||
# Usage: ./scripts/context-sync.sh [adapter...]
|
||||
# Cursor and Aider adapters removed 2026-09-01 — neither is a supported harness
|
||||
# any more. Supported: Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush,
|
||||
# Pi. All read CLAUDE.md or AGENTS.md; system-prompt.txt remains for Open WebUI,
|
||||
# Mods and raw API use.
|
||||
#
|
||||
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
|
||||
# Task: task context:sync
|
||||
#
|
||||
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Parse --force flag and collect adapter names separately
|
||||
FORCE=false
|
||||
ADAPTERS=()
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--force) FORCE=true ;;
|
||||
*) ADAPTERS+=("$_arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROJECT_FILE=".context/PROJECT.md"
|
||||
SKILLS_DIR=".skills"
|
||||
|
||||
# Walk up to find root .context/AGENT.md
|
||||
find_root_context() {
|
||||
@@ -36,19 +50,28 @@ if [ ! -f "$PROJECT_FILE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pre-flight: reject unfilled {{...}} placeholders unless --force
|
||||
if [ "$FORCE" = false ]; then
|
||||
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
|
||||
if [ -n "$_placeholders" ]; then
|
||||
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
|
||||
while IFS= read -r _match; do
|
||||
_lineno="${_match%%:*}"
|
||||
_content="${_match#*:}"
|
||||
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||
done <<< "$_placeholders"
|
||||
echo "" >&2
|
||||
echo "Fill these placeholders, then re-run: task context:sync" >&2
|
||||
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
echo " Root context: $ROOT_CONTEXT"
|
||||
else
|
||||
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
||||
# adapters (AGENTS.md, system-prompt.txt) require the root context to
|
||||
# regenerate correctly, so we skip them entirely and only regenerate CLAUDE.md
|
||||
# (which is project-only and inherits root via tree walk in Claude Code itself).
|
||||
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
||||
echo "Syncing project context from $PROJECT_FILE..."
|
||||
cat "$PROJECT_FILE" > CLAUDE.md
|
||||
echo " → CLAUDE.md (project-only)"
|
||||
echo "Done."
|
||||
exit 0
|
||||
echo " No root AGENT.md found (project context only)"
|
||||
fi
|
||||
|
||||
# Emit root context + separator
|
||||
@@ -95,6 +118,7 @@ generate_system_prompt() {
|
||||
|
||||
# ── MCP config ───────────────────────────────────────────────
|
||||
generate_mcp() {
|
||||
# Ensure baseline file exists with project-specific knowledge server
|
||||
if [ ! -f .context/mcp.json ]; then
|
||||
cat > .context/mcp.json << 'JSON'
|
||||
{
|
||||
@@ -106,27 +130,47 @@ generate_mcp() {
|
||||
}
|
||||
}
|
||||
JSON
|
||||
echo " → .context/mcp.json (new)"
|
||||
else
|
||||
echo " → .context/mcp.json (exists, skipped)"
|
||||
fi
|
||||
|
||||
# Merge root mcp-servers.json if found alongside root AGENT.md
|
||||
local root_mcp=""
|
||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||
local candidate
|
||||
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
|
||||
[ -f "$candidate" ] && root_mcp="$candidate"
|
||||
fi
|
||||
|
||||
if [ -z "$root_mcp" ]; then
|
||||
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
|
||||
return
|
||||
fi
|
||||
|
||||
# Root servers take precedence over project entries on key conflict
|
||||
local root_servers count updated
|
||||
root_servers=$(jq '.servers' "$root_mcp")
|
||||
count=$(printf '%s' "$root_servers" | jq 'keys | length')
|
||||
updated=$(jq --argjson root "$root_servers" \
|
||||
'.mcpServers = (.mcpServers + $root)' \
|
||||
.context/mcp.json)
|
||||
printf '%s\n' "$updated" > .context/mcp.json
|
||||
echo " → .context/mcp.json (merged $count root servers)"
|
||||
}
|
||||
|
||||
echo "Syncing project context from $PROJECT_FILE..."
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
if [ ${#ADAPTERS[@]} -eq 0 ]; then
|
||||
generate_claude
|
||||
generate_agents
|
||||
generate_system_prompt
|
||||
generate_mcp
|
||||
else
|
||||
for adapter in "$@"; do
|
||||
for adapter in "${ADAPTERS[@]}"; do
|
||||
case "$adapter" in
|
||||
claude) generate_claude ;;
|
||||
agents) generate_agents ;;
|
||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||
mcp) generate_mcp ;;
|
||||
*) echo "Unknown adapter: $adapter" ;;
|
||||
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user