Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb2acfeeb8 | ||
|
|
6e5a9cfce3 | ||
|
|
25b79f057e | ||
|
|
47bbac4b9f | ||
|
|
8a27ead91c | ||
|
|
1a9be0cfb1 | ||
|
|
dc2265da0b | ||
|
|
c176b71b3d | ||
|
|
4e8bdc137e | ||
|
|
42235dcb6a | ||
|
|
05330ad54a | ||
|
|
692244d213 | ||
|
|
d98a305985 | ||
|
|
670325e519 | ||
|
|
54237af7ad | ||
|
|
b6ceccca26 | ||
|
|
36a45068ab | ||
|
|
055af368cd | ||
|
|
eb9e3e69a9 | ||
|
|
b7cf0efb9d | ||
|
|
125c99bb46 | ||
|
|
3e537bd582 | ||
|
|
4e6fc7aeee | ||
|
|
9a232ad444 | ||
|
|
3da0eca9b4 | ||
|
|
7d1213451c | ||
|
|
7c9293f4ed | ||
|
|
6e0155a2ab | ||
|
|
1cea2c9f78 |
@@ -7,6 +7,29 @@
|
|||||||
"supervisor": {
|
"supervisor": {
|
||||||
"url": "http://localhost:3200/mcp",
|
"url": "http://localhost:3200/mcp",
|
||||||
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
"description": "Hyperguild SDO — skill workers (tdd, retrospective), brain tools (brain_query, brain_write), session logging, tier detection"
|
||||||
|
},
|
||||||
|
"brain": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"infra": {
|
||||||
|
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8444/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-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
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
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
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
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
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -78,6 +94,26 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -140,10 +176,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | 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)
|
- **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
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -239,10 +275,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- 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).
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -254,15 +291,29 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `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`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -273,7 +324,20 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). 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:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Presence of this file marks this repo as opt-in for headless dispatch.
|
||||||
|
# See dispatch#3.
|
||||||
@@ -45,6 +45,12 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
|
||||||
IMAGE_TAG="${{ github.sha }}"
|
IMAGE_TAG="${{ github.sha }}"
|
||||||
|
# infra#311: github.sha is never empty in practice, but the manifest
|
||||||
|
# patch below is the same shape that took Flux down estate-wide on
|
||||||
|
# 2026-08-25 -- an empty tag yields `image: <repo>:`, which is invalid
|
||||||
|
# YAML and fails the ENTIRE Kustomization, not just this app. The
|
||||||
|
# assert costs nothing and removes the class.
|
||||||
|
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
|
||||||
chmod 600 ~/.ssh/infra_deploy_key
|
chmod 600 ~/.ssh/infra_deploy_key
|
||||||
|
|||||||
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"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# gitleaks config for the hyperguild repo (infra#39 — leak prevention pass,
|
||||||
|
# Phase 3 checklist item: "gitleaks pre-commit hook in infra AND hyperguild").
|
||||||
|
#
|
||||||
|
# Ported from mathias/infra's .gitleaks.toml (2026-08-04) — same homelab
|
||||||
|
# token-shape rules, minus the SOPS/searxng allowlists infra needed (this
|
||||||
|
# repo doesn't use SOPS).
|
||||||
|
|
||||||
|
title = "hyperguild gitleaks config"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
# --- Homelab-specific rules -------------------------------------------------
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "homelab-static-bearer"
|
||||||
|
description = "Homelab MCP/LLM static bearer or API key assigned a long literal value"
|
||||||
|
regex = '''(?i)\b(DMABE_[A-Z0-9_]+|[A-Z0-9_]*MCP_TOKEN|ROUTING_MCP_TOKEN|INFRA_MCP_TOKEN|BRAIN_MCP_TOKEN|GITEA_MCP_TOKEN|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|DMABE_LLMAPI_KEY|BRAIN_PG_DSN)\s*[:=]\s*['"]?([A-Za-z0-9/_+.\-]{16,})['"]?'''
|
||||||
|
keywords = ["dmabe_", "mcp_token", "litellm_master_key", "litellm_salt_key", "llmapi_key", "brain_pg_dsn"]
|
||||||
|
[[rules.allowlists]]
|
||||||
|
description = "Env indirection is not a literal secret"
|
||||||
|
regexes = [
|
||||||
|
'''os\.environ''',
|
||||||
|
'''valueFrom''',
|
||||||
|
'''secretKeyRef''',
|
||||||
|
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||||
|
'''REDACTED''',
|
||||||
|
'''<[A-Z_]+>''',
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "homelab-authorization-bearer"
|
||||||
|
description = "Hardcoded Authorization: Bearer header"
|
||||||
|
regex = '''(?i)authorization['"]?\s*[:=]\s*['"]?bearer\s+([A-Za-z0-9/_+.\-=]{16,})'''
|
||||||
|
keywords = ["authorization", "bearer"]
|
||||||
|
[[rules.allowlists]]
|
||||||
|
description = "Env indirection is not a literal secret"
|
||||||
|
regexes = [
|
||||||
|
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
|
||||||
|
'''os\.environ''',
|
||||||
|
'''REDACTED''',
|
||||||
|
'''<[A-Z_]+>''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Global allowlist: claudewatcher's own scrubber test fixtures ------------
|
||||||
|
# ingestion/internal/claudewatcher/{scrubber,watcher}_test.go deliberately
|
||||||
|
# contain fake secret-shaped literals to test that the scrubber detects and
|
||||||
|
# redacts them. Verified 2026-08-04: all 9 findings here are test fixtures
|
||||||
|
# (github-pat, jwt, generic-api-key, homelab-authorization-bearer rules) plus
|
||||||
|
# 1 doc finding that was gitleaks matching the literal placeholder word
|
||||||
|
# "REDACTED" in a plan doc — not a real secret in either case.
|
||||||
|
[[allowlists]]
|
||||||
|
description = "claudewatcher scrubber test fixtures — deliberately fake secrets"
|
||||||
|
paths = [
|
||||||
|
'''ingestion/internal/claudewatcher/scrubber_test\.go$''',
|
||||||
|
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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
|
logical change (one tool, one fix, one test) with passing tests. Main is always
|
||||||
deployable. Never create long-lived feature branches.
|
deployable. Never create long-lived feature branches.
|
||||||
|
|
||||||
**Exception — parallel agents on same repo:** If another agent is known to be
|
**The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
|
||||||
actively working on the same repo simultaneously, create a short-lived branch
|
main with CI as the gate — that is most work and is unchanged. `risk/medium` and
|
||||||
(`agent/<description>`), finish the task, and merge to main within the same
|
above opens a PR, even solo, even with nobody waiting to review it. This mirrors
|
||||||
session. Do not leave agent branches open between sessions.
|
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
|
**Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
|
||||||
PR flow only when a human reviewer outside the project is required. Document
|
collisions between parallel sessions: two were not branching problems at all — a
|
||||||
the reason in PROJECT.md.
|
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
|
6. **Close the loop — every substantive task ends with the same ritual.** Shipping
|
||||||
the code is not the end of the task; capturing it is. Run this unprompted:
|
the code is not the end of the task; capturing it is. Run this unprompted:
|
||||||
@@ -73,6 +89,26 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
**Chat is not a durable store. Never rely on Mathias remembering anything.**
|
||||||
|
Every gap, caveat, risk, open question and next step must land in one of four
|
||||||
|
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
|
||||||
|
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
|
||||||
|
lost the moment the session closes — and this estate has repeatedly proved that a
|
||||||
|
remembered convention is an unreliable control.
|
||||||
|
|
||||||
|
This applies hardest to what is tempting to leave as a closing remark: "worth
|
||||||
|
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
|
||||||
|
revisit Y". Those are exactly the items that need a locator. If it is worth
|
||||||
|
saying in a summary, it is worth writing where it can be found again without you.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
@@ -135,10 +171,10 @@ Three machines on Tailscale:
|
|||||||
|---------|------|-----------|
|
|---------|------|-----------|
|
||||||
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
|
||||||
| iguana | Services, builds | M2 Ultra Mac |
|
| iguana | Services, builds | M2 Ultra Mac |
|
||||||
| flamingo | Daily driver, edge | 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)
|
- **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
|
- **Networking**: Tailscale mesh
|
||||||
|
|
||||||
## Project landscape
|
## Project landscape
|
||||||
@@ -234,10 +270,11 @@ When working on a project tagged with a client name:
|
|||||||
|
|
||||||
## Harness-agnostic principles
|
## Harness-agnostic principles
|
||||||
|
|
||||||
This context is designed to work with any AI coding tool:
|
Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
|
||||||
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush
|
re-add adapters for them:
|
||||||
- Pi Coding Agent, Mistral Vibe, Antigravity
|
- Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
|
||||||
- Any tool that accepts a system prompt or reads a markdown context file
|
- 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).
|
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
|
||||||
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
|
||||||
@@ -249,15 +286,29 @@ Canonical sources of truth:
|
|||||||
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
- Project: `<repo>/.context/PROJECT.md` (per-repo)
|
||||||
|
|
||||||
Derived files (committed, regenerated by `task context:sync`):
|
Derived files (committed, regenerated by `task context:sync`):
|
||||||
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`,
|
- `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
|
||||||
`.context/system-prompt.txt`
|
|
||||||
|
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
|
||||||
|
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
|
||||||
|
you touch the repo, along with their entries in that repo's `task check`
|
||||||
|
assertion.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
1. Edit a canonical file. Run `task context:sync`. Commit canonical and
|
||||||
derived together. Push.
|
derived together. Push.
|
||||||
2. On any other host, `git pull` brings both. Claude Code (tree-walking)
|
2. On any other host, `git pull` brings both. Which file a harness reads does
|
||||||
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`;
|
NOT split cleanly by lookup strategy, so do not reason from the strategy:
|
||||||
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`.
|
- `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`
|
3. `task check` runs `context:sync` then asserts `git status --porcelain`
|
||||||
is empty over the derived files (catches both modified-tracked drift
|
is empty over the derived files (catches both modified-tracked drift
|
||||||
and missing-untracked adapters). A drift fails the check with a
|
and missing-untracked adapters). A drift fails the check with a
|
||||||
@@ -268,7 +319,20 @@ unconditionally on every host, every harness.
|
|||||||
|
|
||||||
## Engineering Skills
|
## Engineering Skills
|
||||||
|
|
||||||
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). 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:**
|
**Skill trigger table — load before starting, not after getting stuck:**
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
|
|||||||
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
|
||||||
into a searchable brain.
|
into a searchable brain.
|
||||||
|
|
||||||
|
**This repo is also where `brain-mcp` lives** — the MCP server at
|
||||||
|
`brain-mcp.d-ma.be` that `brain_query`, `brain_write`, `brain_read`, `brain_edit`
|
||||||
|
and `capture` are served from. It is not a separate deployable; see
|
||||||
|
[Where brain-mcp lives](#where-brain-mcp-lives).
|
||||||
|
|
||||||
## Hypothesis
|
## Hypothesis
|
||||||
|
|
||||||
> We believe routing skill tasks through local models, backed by brain context,
|
> We believe routing skill tasks through local models, backed by brain context,
|
||||||
@@ -20,7 +25,7 @@ usage, the fast-model routing path should be reconsidered.
|
|||||||
|
|
||||||
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
|
||||||
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
|
||||||
[agentsquad](http://gitea.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
[agentsquad](https://git.d-ma.be/mathias/agentsquad) — a separate harness for a different
|
||||||
orchestration model. Skills (mathias/skills) are shared between both.
|
orchestration model. Skills (mathias/skills) are shared between both.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
@@ -44,6 +49,33 @@ brain/
|
|||||||
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Where brain-mcp lives
|
||||||
|
|
||||||
|
`brain-mcp` is served from **`ingestion/internal/mcp`** in this repo, at
|
||||||
|
**`brain-mcp.d-ma.be`**. It is a component *inside* the `ingestion` service, not
|
||||||
|
a standalone deployable — one pod in the `supervisor` namespace serves the MCP
|
||||||
|
endpoint, the REST API, and the claudewatcher together.
|
||||||
|
|
||||||
|
| You are looking for | It is here |
|
||||||
|
|---|---|
|
||||||
|
| The MCP tool surface (`brain_query`, `brain_write`, `brain_read`, `brain_edit`, `capture`, …) | `ingestion/internal/mcp` |
|
||||||
|
| The HTTP/REST API (`/query`, `/write`, `/ingest`) | `ingestion/internal/api` |
|
||||||
|
| Session ingestion from Claude Code transcripts | `ingestion/internal/claudewatcher` |
|
||||||
|
| The brain *content* (notes, wings, halls) | [`mathias/brain`](https://git.d-ma.be/mathias/brain) — a separate repo |
|
||||||
|
| Deployment manifests | `config/supervisor` |
|
||||||
|
|
||||||
|
### Why it is not its own repo
|
||||||
|
|
||||||
|
Asked and decided in [#91](https://git.d-ma.be/mathias/hyperguild/issues/91)
|
||||||
|
(part 2). `internal/mcp` imports 17 of ingestion's other internal packages and
|
||||||
|
has exactly one importer — it is the MCP facade over the service's own
|
||||||
|
internals, so "move it out" resolves to renaming `ingestion`, publishing 17
|
||||||
|
packages as a shared library, or maintaining a shim. The discoverability problem
|
||||||
|
that prompted the question is answered by naming it here instead.
|
||||||
|
|
||||||
|
Full reasoning, including the reversal trigger: brain
|
||||||
|
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
||||||
|
|
||||||
## Phase 1 tools (available now)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
@@ -58,7 +90,7 @@ brain/
|
|||||||
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
|
||||||
|
|
||||||
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
|
||||||
> They are now SKILL.md files in [mathias/skills](http://gitea.d-ma.be/mathias/skills).
|
> They are now SKILL.md files in [mathias/skills](https://git.d-ma.be/mathias/skills).
|
||||||
|
|
||||||
## Connect a project
|
## Connect a project
|
||||||
|
|
||||||
@@ -147,7 +179,7 @@ The routing pod selects models per skill call based on historical pass rate:
|
|||||||
|
|
||||||
## Open issues
|
## Open issues
|
||||||
|
|
||||||
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items:
|
See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
|
||||||
|
|
||||||
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
|
||||||
- **#24** — reduce context burn from skill listing
|
- **#24** — reduce context burn from skill listing
|
||||||
|
|||||||
+56
-1
@@ -86,8 +86,26 @@ tasks:
|
|||||||
check:
|
check:
|
||||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
- 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: |
|
- cmd: |
|
||||||
|
if [ -n "${CI:-}" ]; then
|
||||||
|
echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
|
||||||
|
else
|
||||||
|
bash scripts/context-sync.sh
|
||||||
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
|
||||||
if [ -n "$drift" ]; then
|
if [ -n "$drift" ]; then
|
||||||
echo "ERROR: derived adapters drifted from canonical context." >&2
|
echo "ERROR: derived adapters drifted from canonical context." >&2
|
||||||
@@ -98,15 +116,52 @@ tasks:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✓ context: canonical and adapters are in sync"
|
echo "✓ context: canonical and adapters are in sync"
|
||||||
|
fi
|
||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
|
- task: security:gitleaks
|
||||||
|
|
||||||
|
# ── Security ─────────────────────────────────────────────
|
||||||
|
security:gitleaks:
|
||||||
|
desc: Scan the working tree for secrets (gitleaks, fail-closed; skipped if gitleaks absent)
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "⚠ gitleaks not installed — skipping secret scan (CI enforces it)."
|
||||||
|
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
"$GL" detect --no-git --redact --config .gitleaks.toml --source .
|
||||||
|
|
||||||
|
security:gitleaks:history:
|
||||||
|
desc: "One-time FULL-HISTORY secret audit (infra#39 rotation pass; not a per-push gate)"
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "gitleaks not installed: go install github.com/zricethezav/gitleaks/v8@latest" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
echo "Scanning FULL git history (redacted). Known historical leaks are expected"
|
||||||
|
echo "until the infra#39 rotation pass completes — triage against the rotation list."
|
||||||
|
"$GL" detect --redact --config .gitleaks.toml
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run ./...
|
- golangci-lint run ./...
|
||||||
- cd ingestion && golangci-lint run ./...
|
- cd ingestion && golangci-lint run ./...
|
||||||
|
|
||||||
|
lint:frontmatter:
|
||||||
|
desc: Report brain notes whose YAML frontmatter does not parse (BRAIN=path, FIX=1 repairs in place)
|
||||||
|
cmds:
|
||||||
|
- cd ingestion && go run ./cmd/frontmatterlint {{if .FIX}}-fix{{end}} {{.BRAIN | default "../../brain"}}
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cmds:
|
cmds:
|
||||||
- go test -race -count=1 ./...
|
- go test -race -count=1 ./...
|
||||||
|
|||||||
@@ -1051,7 +1051,7 @@ git push
|
|||||||
```bash
|
```bash
|
||||||
# Poll until complete (replace RUN_ID with the new run):
|
# Poll until complete (replace RUN_ID with the new run):
|
||||||
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
|
||||||
-H "Authorization: token 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']]"
|
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Command frontmatterlint reports markdown notes whose YAML frontmatter does
|
||||||
|
// not parse, and optionally repairs the ones that only need quoting.
|
||||||
|
//
|
||||||
|
// go run ./cmd/frontmatterlint ../../brain # report
|
||||||
|
// go run ./cmd/frontmatterlint -fix ../../brain # repair in place
|
||||||
|
//
|
||||||
|
// Exit codes: 0 clean (or everything repaired), 1 findings remain, 2 the scan
|
||||||
|
// itself failed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fix := flag.Bool("fix", false, "repair files that quoting can rescue, in place")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, err := frontmatter.Scan(root, *fix)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "frontmatterlint:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolved int
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.Repaired {
|
||||||
|
fmt.Printf("repaired %s\n", f.Path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unresolved++
|
||||||
|
fmt.Printf("BROKEN %s: %v\n", f.Path, f.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unresolved > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%d file(s) with unparseable frontmatter", unresolved)
|
||||||
|
if !*fix {
|
||||||
|
fmt.Fprint(os.Stderr, " — re-run with -fix")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ frontmatter: %d file(s) repaired, none broken\n", len(findings))
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/webhook"
|
||||||
@@ -143,6 +144,16 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildNotifier returns the ntfy notifier, or a nil interface when
|
||||||
|
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||||
|
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
||||||
|
func buildNotifier() audit.Notifier {
|
||||||
|
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
|
||||||
// set it builds the classification-aware DegradingSink (loki central +
|
// set it builds the classification-aware DegradingSink (loki central +
|
||||||
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
// durable file buffer + optional ntfy) and starts the reconcile loop;
|
||||||
@@ -160,12 +171,7 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
|
|||||||
logger.Error("capture audit buffer init", "err", err)
|
logger.Error("capture audit buffer init", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
|
notifier := buildNotifier()
|
||||||
// so DegradingSink/Reconcile skip it cleanly.
|
|
||||||
var notifier audit.Notifier
|
|
||||||
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
|
|
||||||
notifier = n
|
|
||||||
}
|
|
||||||
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
|
||||||
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
|
||||||
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
|
||||||
@@ -230,6 +236,11 @@ func main() {
|
|||||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||||
}
|
}
|
||||||
|
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
|
||||||
|
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
|
||||||
|
tertiaryKey := os.Getenv("GEMINI_API_KEY")
|
||||||
|
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
|
||||||
|
}
|
||||||
answerComplete = router.Complete
|
answerComplete = router.Complete
|
||||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||||
}
|
}
|
||||||
@@ -314,6 +325,36 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Built before the watcher, the graph backfill and session ingestion, and
|
||||||
|
// retried rather than fatal (#83). Two separate reasons:
|
||||||
|
//
|
||||||
|
// - On a cold cluster boot this pod can beat dex to serving and gets a
|
||||||
|
// 502 from ingress. Nothing is misconfigured; it simply started first.
|
||||||
|
// Exiting there meant CrashLoopBackOff redoing the backfill on every
|
||||||
|
// attempt, with exponential backoff stretching the brain outage.
|
||||||
|
// - Constructing it first means a genuine config error (wrong issuer,
|
||||||
|
// bad audience) fails in seconds instead of after all that work.
|
||||||
|
//
|
||||||
|
// The budget stays bounded: retrying forever would turn a misconfiguration
|
||||||
|
// into a process that looks alive and never serves.
|
||||||
|
jwtValidator, err := startupretry.Do(ctx,
|
||||||
|
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second},
|
||||||
|
func(ctx context.Context) (*chassisauth.JWTValidator, error) {
|
||||||
|
v, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("oidc discovery not ready, retrying", "err", err)
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("build jwt validator", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if jwtValidator != nil {
|
||||||
|
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
||||||
|
}
|
||||||
|
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
watcher.Start(ctx, watcher.Config{
|
watcher.Start(ctx, watcher.Config{
|
||||||
BrainDir: brainDir,
|
BrainDir: brainDir,
|
||||||
@@ -355,6 +396,29 @@ func main() {
|
|||||||
}
|
}
|
||||||
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
||||||
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
||||||
|
|
||||||
|
// Project directories excluded from ingestion entirely, as globs
|
||||||
|
// against the project directory name — e.g.
|
||||||
|
// "*cobalt-dingo*,*claude-worktrees-bridge-cse*".
|
||||||
|
//
|
||||||
|
// This is a veto list, deliberately named as one. It complements the
|
||||||
|
// client-name scrubber rather than replacing it: the scrubber drops
|
||||||
|
// individual TURNS whose content matches, while this never opens a
|
||||||
|
// project's files at all. Sessions working on live company financials
|
||||||
|
// are excluded here because their content is confidential in ways no
|
||||||
|
// pattern reliably recognises, and their learnings are captured
|
||||||
|
// deliberately through the session-close ritual instead.
|
||||||
|
//
|
||||||
|
// Caveat worth knowing: a project directory is derived from the
|
||||||
|
// session's WORKING DIRECTORY, not from the repositories it touches.
|
||||||
|
// A session run from ~/dev that happens to read a skipped repo will
|
||||||
|
// NOT be excluded by this. The scrubber remains the control for
|
||||||
|
// content; this is a control for whole workspaces.
|
||||||
|
skipProjects := splitList(os.Getenv("CLAUDE_INGEST_SKIP_PROJECTS"))
|
||||||
|
if len(skipProjects) > 0 {
|
||||||
|
logger.Info("claudewatcher: projects excluded from ingestion", "patterns", skipProjects)
|
||||||
|
}
|
||||||
|
|
||||||
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
||||||
go func() {
|
go func() {
|
||||||
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
||||||
@@ -364,6 +428,7 @@ func main() {
|
|||||||
Sink: sink,
|
Sink: sink,
|
||||||
Cursors: cursorStore,
|
Cursors: cursorStore,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
SkipProjects: skipProjects,
|
||||||
}); err != nil && err != context.Canceled {
|
}); err != nil && err != context.Canceled {
|
||||||
logger.Error("claudewatcher exited", "err", err)
|
logger.Error("claudewatcher exited", "err", err)
|
||||||
}
|
}
|
||||||
@@ -418,15 +483,6 @@ func main() {
|
|||||||
if webhookHandler != nil {
|
if webhookHandler != nil {
|
||||||
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
mux.Handle("POST /webhooks/brain-sync", webhookHandler)
|
||||||
}
|
}
|
||||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("build jwt validator", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if jwtValidator != nil {
|
|
||||||
logger.Info("jwt auth enabled", "issuer", os.Getenv("DEX_ISSUER_URL"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
// Resource-metadata URL is only emitted on 401 when Dex OAuth is
|
||||||
// configured. Static-Bearer-only deployments leave this empty so
|
// configured. Static-Bearer-only deployments leave this empty so
|
||||||
// clients never see an OAuth challenge.
|
// clients never see an OAuth challenge.
|
||||||
@@ -457,12 +513,8 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
auditSink := buildAuditSink(ctx, brainDir, logger)
|
auditSink := buildAuditSink(ctx, brainDir, logger)
|
||||||
// The Gitea client also satisfies SummaryWriter (#66): session
|
|
||||||
// summaries are written to mathias/ai-sessions over the same API
|
|
||||||
// token. nil only if a future tracker impl lacks file writes.
|
|
||||||
summaryWriter, _ := tracker.(capture.SummaryWriter)
|
|
||||||
captureSvc := capture.NewService(
|
captureSvc := capture.NewService(
|
||||||
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink)
|
mcpSrv.BrainStore(), tracker, classCfg, auditSink)
|
||||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
resolver := capturehttp.NewOriginResolver(sovereign)
|
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
@@ -472,7 +524,13 @@ func main() {
|
|||||||
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
|
||||||
// WithCapture mutates the same instance, so the tool appears live.
|
// WithCapture mutates the same instance, so the tool appears live.
|
||||||
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
|
// hyperguild#97: a governance-file rewrite gets the same trail a
|
||||||
|
// capture already gets, plus an out-of-band alert. Wired here because
|
||||||
|
// brain_edit_root needs the capture plumbing anyway — that is what
|
||||||
|
// re-derives the static principal its gate checks.
|
||||||
|
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
|
||||||
|
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
|
||||||
|
"governance_observer", true)
|
||||||
} else {
|
} else {
|
||||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
@@ -132,6 +133,16 @@ func WriteNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("content is required")
|
return "", fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In the primitive, not in a caller: POST /write reaches here directly,
|
||||||
|
// without the BrainStore, and a gate one door wide is not a gate.
|
||||||
|
// BrainStore still calls the strip itself to report Ref.Unlinked; the
|
||||||
|
// second pass is a no-op on already-clean content.
|
||||||
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
opts.Content = clean
|
||||||
|
|
||||||
if opts.Wing != "" && opts.Hall != "" {
|
if opts.Wing != "" && opts.Hall != "" {
|
||||||
return writeHallNote(brainDir, opts)
|
return writeHallNote(brainDir, opts)
|
||||||
}
|
}
|
||||||
@@ -152,15 +163,24 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
||||||
return "", fmt.Errorf("create hall dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
existingFields, body := splitFrontmatter(opts.Content)
|
existingFields, body := splitFrontmatter(opts.Content)
|
||||||
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||||
for _, f := range existingFields {
|
for _, f := range existingFields {
|
||||||
existingByKey[f.key] = f
|
existingByKey[f.key] = f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the claim-property envelope BEFORE creating the directory, so a
|
||||||
|
// rejected write leaves nothing behind — not even an empty hall dir
|
||||||
|
// (brain#21). Fail closed: a value outside the enum is worse than an absent
|
||||||
|
// one, because a lint skips what it cannot parse and the note then reads as
|
||||||
|
// classified while nothing acts on it.
|
||||||
|
if err := claim.ValidateEnvelope(scalarValues(existingFields)); err != nil {
|
||||||
|
return "", fmt.Errorf("claim envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create hall dir: %w", err)
|
||||||
|
}
|
||||||
emitted := make(map[string]bool, 6)
|
emitted := make(map[string]bool, 6)
|
||||||
|
|
||||||
var fm strings.Builder
|
var fm strings.Builder
|
||||||
@@ -218,6 +238,25 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
|||||||
return filepath.ToSlash(rel), nil
|
return filepath.ToSlash(rel), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scalarValues flattens frontmatter fields to key -> scalar value for
|
||||||
|
// validation. Multi-line values (lists, nested blocks) yield the text after
|
||||||
|
// the colon on the first line, which is empty for a list — correct here,
|
||||||
|
// since every claim-property field is a scalar enum.
|
||||||
|
func scalarValues(fields []frontmatterField) map[string]string {
|
||||||
|
out := make(map[string]string, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if len(f.lines) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, value, found := strings.Cut(f.lines[0], ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[f.key] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// frontmatterField is one top-level YAML key from a frontmatter block,
|
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||||
// along with its raw line and any indented continuation lines (e.g. a
|
// along with its raw line and any indented continuation lines (e.g. a
|
||||||
// bulleted list value spanning multiple lines).
|
// bulleted list value spanning multiple lines).
|
||||||
|
|||||||
@@ -440,3 +440,102 @@ func TestIngestPath_Directory(t *testing.T) {
|
|||||||
require.True(t, ok, "pages must be an array")
|
require.True(t, ok, "pages must be an array")
|
||||||
assert.NotEmpty(t, pagesSlice)
|
assert.NotEmpty(t, pagesSlice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- brain#21: claim-property envelope on the write path -------------------
|
||||||
|
|
||||||
|
func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) {
|
||||||
|
// writeHallNote already passes unknown keys through; this pins that
|
||||||
|
// behaviour for the envelope specifically, so a future refactor of the
|
||||||
|
// merge loop cannot silently drop it.
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "decisions",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "half_life: durable")
|
||||||
|
assert.Contains(t, string(got), "applies: universal")
|
||||||
|
assert.Contains(t, string(got), "claim_provenance: asserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) {
|
||||||
|
// Fail closed: a value outside the enum must not land. A lint skips what
|
||||||
|
// it cannot parse, so an unvalidated write produces a note that reads as
|
||||||
|
// classified while nothing acts on it.
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n",
|
||||||
|
Filename: "bad-envelope",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "half_life")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "---\napplies: homelab\n---\n\nBody.\n",
|
||||||
|
Filename: "no-provenance",
|
||||||
|
Wing: "homelab",
|
||||||
|
Hall: "facts",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim_provenance")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HTTP POST /write door reaches WriteNote directly, without the
|
||||||
|
// BrainStore, so the unresolved-wikilink strip has to live in the primitive
|
||||||
|
// or that door stays ungated.
|
||||||
|
func TestWriteNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "See [[target]] and [[gitea-ci]].\n",
|
||||||
|
Filename: "src", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "[[target]]")
|
||||||
|
assert.Contains(t, string(got), "and gitea-ci.")
|
||||||
|
assert.NotContains(t, string(got), "[[gitea-ci]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNote_UnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rel, _, _, err := api.UpdateNote(dir, api.UpdateNoteOptions{
|
||||||
|
Wing: "a", Hall: "facts", Slug: "n",
|
||||||
|
Content: "Related: [[secret-hygiene]].\n", Reason: "r",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Related: secret-hygiene.")
|
||||||
|
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
return "", "", "", fmt.Errorf("content is required")
|
return "", "", "", fmt.Errorf("content is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same reason as WriteNote: the strip belongs in the primitive so every
|
||||||
|
// door inherits it.
|
||||||
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
opts.Content = clean
|
||||||
|
|
||||||
var rel string
|
var rel string
|
||||||
if opts.Path != "" {
|
if opts.Path != "" {
|
||||||
rel = opts.Path
|
rel = opts.Path
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package brain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wikilinkRE matches [[target]] and [[target|Display Text]].
|
||||||
|
var wikilinkRE = regexp.MustCompile(`\[\[([^\]|]+)(?:\|([^\]]*))?\]\]`)
|
||||||
|
|
||||||
|
// fencedCodeRE and inlineCodeRE mark the regions where a [[...]] is a
|
||||||
|
// syntax example rather than a link. Same exclusion brain-gardener's audit
|
||||||
|
// applies on the read side, so the write gate and the audit agree on what
|
||||||
|
// counts as a link at all.
|
||||||
|
var (
|
||||||
|
fencedCodeRE = regexp.MustCompile("(?s)```.*?```")
|
||||||
|
inlineCodeRE = regexp.MustCompile("`[^`\n]*`")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StripUnresolvedLinks unwraps every [[wikilink]] in content whose target
|
||||||
|
// resolves to no entry under knowledge/ or wiki/, leaving the link's display
|
||||||
|
// text (or its target) as plain prose. It returns the rewritten content and
|
||||||
|
// the sorted, de-duplicated targets it unwrapped.
|
||||||
|
//
|
||||||
|
// WHY UNWRAP RATHER THAN REJECT THE WRITE
|
||||||
|
// Agents write [[...]] around anything that feels related — a skill name, a
|
||||||
|
// repo, an agent-local memory file — and none of those are brain entries.
|
||||||
|
// Every one of those becomes a permanent [high] broken-link finding that no
|
||||||
|
// deterministic Fixer can repair, because there is no target to repoint to:
|
||||||
|
// the brain-audit digest grew from 19 to 63 high findings between 2026-08-26
|
||||||
|
// and 2026-09-16, and 53 of the 63 were exactly this. Rejecting the write
|
||||||
|
// instead would push the failure onto claudewatcher's unattended ingest path,
|
||||||
|
// where a refused write is a lost learning. Unwrapping keeps the author's
|
||||||
|
// words, keeps the write, and keeps the graph honest.
|
||||||
|
//
|
||||||
|
// An empty corpus (no knowledge/ or wiki/ yet, or a misconfigured brainDir)
|
||||||
|
// returns content unchanged: nothing resolving is a bootstrap state, not
|
||||||
|
// evidence that every target was invented.
|
||||||
|
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
|
||||||
|
index, err := entryIndex(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(index) == 0 {
|
||||||
|
return content, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := codeSpans(content)
|
||||||
|
stripped := map[string]struct{}{}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
last := 0
|
||||||
|
for _, m := range wikilinkRE.FindAllStringSubmatchIndex(content, -1) {
|
||||||
|
start, end := m[0], m[1]
|
||||||
|
if overlaps(start, end, code) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(content[m[2]:m[3]])
|
||||||
|
if _, ok := index[linkKey(target)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
display := target
|
||||||
|
if m[4] >= 0 {
|
||||||
|
if d := strings.TrimSpace(content[m[4]:m[5]]); d != "" {
|
||||||
|
display = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(content[last:start])
|
||||||
|
b.WriteString(display)
|
||||||
|
last = end
|
||||||
|
stripped[target] = struct{}{}
|
||||||
|
}
|
||||||
|
if last == 0 {
|
||||||
|
return content, nil, nil
|
||||||
|
}
|
||||||
|
b.WriteString(content[last:])
|
||||||
|
|
||||||
|
targets := make([]string, 0, len(stripped))
|
||||||
|
for t := range stripped {
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
sort.Strings(targets)
|
||||||
|
return b.String(), targets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkedTrees are the two trees that hold linkable entries — the same pair
|
||||||
|
// package notes admits under ScopeNotes. Duplicated rather than imported
|
||||||
|
// because that list is unexported there and this is a read-only mirror.
|
||||||
|
var linkedTrees = []string{"knowledge", "wiki"}
|
||||||
|
|
||||||
|
// entryIndex maps every form a wikilink may name an entry by — its bare slug
|
||||||
|
// and its path form (wiki/<wing>/<hall>/<slug>.md -> wing/hall/slug) — to a
|
||||||
|
// present marker. Mirrors brain-gardener's resolver so an accepted link is
|
||||||
|
// one the audit will also resolve.
|
||||||
|
func entryIndex(brainDir string) (map[string]struct{}, error) {
|
||||||
|
index := map[string]struct{}{}
|
||||||
|
for _, dir := range linkedTrees {
|
||||||
|
root := filepath.Join(brainDir, dir)
|
||||||
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(brainDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
|
||||||
|
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("walk %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkKey normalises a link target for index lookup: an exact path form is
|
||||||
|
// tried as written, and anything else falls back to its final segment.
|
||||||
|
func linkKey(target string) string {
|
||||||
|
return strings.TrimSuffix(target, ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeSpans returns the byte ranges of fenced blocks and inline code spans.
|
||||||
|
func codeSpans(content string) [][2]int {
|
||||||
|
var spans [][2]int
|
||||||
|
for _, m := range fencedCodeRE.FindAllStringIndex(content, -1) {
|
||||||
|
spans = append(spans, [2]int{m[0], m[1]})
|
||||||
|
}
|
||||||
|
for _, m := range inlineCodeRE.FindAllStringIndex(content, -1) {
|
||||||
|
spans = append(spans, [2]int{m[0], m[1]})
|
||||||
|
}
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlaps reports whether [start,end) intersects any span.
|
||||||
|
func overlaps(start, end int, spans [][2]int) bool {
|
||||||
|
for _, s := range spans {
|
||||||
|
if start < s[1] && end > s[0] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCorpus writes the two entries every linkcheck case resolves against.
|
||||||
|
func seedCorpus(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
seedNote(t, dir, "wiki/homelab/facts/real-entry.md",
|
||||||
|
"---\nwing: homelab\nhall: facts\ntitle: 'Real entry'\n---\n\nbody.\n")
|
||||||
|
seedNote(t, dir, "knowledge/legacy-entry.md",
|
||||||
|
"---\ntitle: 'Legacy entry'\n---\n\nbody.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUnresolvedLinks(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
stripped []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bare slug that resolves is kept",
|
||||||
|
body: "See [[real-entry]] for the rule.",
|
||||||
|
want: "See [[real-entry]] for the rule.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path-style target that resolves is kept",
|
||||||
|
body: "See [[homelab/facts/real-entry]].",
|
||||||
|
want: "See [[homelab/facts/real-entry]].",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "knowledge/ entry resolves by bare slug",
|
||||||
|
body: "See [[legacy-entry]].",
|
||||||
|
want: "See [[legacy-entry]].",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable target is unwrapped to plain text",
|
||||||
|
body: "Related: [[secret-hygiene]].",
|
||||||
|
want: "Related: secret-hygiene.",
|
||||||
|
stripped: []string{"secret-hygiene"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unresolvable aliased target keeps its display text",
|
||||||
|
body: "Related: [[feedback_check_host_not_assume|check the host]].",
|
||||||
|
want: "Related: check the host.",
|
||||||
|
stripped: []string{"feedback_check_host_not_assume"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "link inside a fenced code block is left alone",
|
||||||
|
body: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||||
|
want: "Syntax:\n\n```\n[[wing/hall/slug]]\n```\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "link inside an inline code span is left alone",
|
||||||
|
body: "Write `[[wing/hall/slug]]` to link.",
|
||||||
|
want: "Write `[[wing/hall/slug]]` to link.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "every unresolvable target is reported once, sorted",
|
||||||
|
body: "[[zeta-gap]] and [[alpha-gap]] and [[zeta-gap]] again.",
|
||||||
|
want: "zeta-gap and alpha-gap and zeta-gap again.",
|
||||||
|
stripped: []string{"alpha-gap", "zeta-gap"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedCorpus(t, dir)
|
||||||
|
|
||||||
|
got, stripped, err := brain.StripUnresolvedLinks(dir, tc.body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
assert.Equal(t, tc.stripped, stripped)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUnresolvedLinks_MissingBrainDirIsNotAGate(t *testing.T) {
|
||||||
|
// A brain dir with no knowledge//wiki/ tree yet must not turn every link
|
||||||
|
// into an unresolved one — an empty corpus is a bootstrap state, not
|
||||||
|
// evidence that the author invented the target.
|
||||||
|
got, stripped, err := brain.StripUnresolvedLinks(t.TempDir(), "See [[real-entry]].")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "See [[real-entry]].", got)
|
||||||
|
assert.Nil(t, stripped)
|
||||||
|
}
|
||||||
@@ -42,6 +42,12 @@ func (s *Store) WithGraph(g graphsync.Store) *Store {
|
|||||||
|
|
||||||
// Write creates a brain note and returns its read-after-write handle.
|
// Write creates a brain note and returns its read-after-write handle.
|
||||||
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) {
|
||||||
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
n.Content = content
|
||||||
|
|
||||||
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{
|
||||||
Content: n.Content,
|
Content: n.Content,
|
||||||
Filename: n.Filename,
|
Filename: n.Filename,
|
||||||
@@ -57,13 +63,19 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error)
|
|||||||
s.indexInGraph(ctx, "brain_write", relPath)
|
s.indexInGraph(ctx, "brain_write", relPath)
|
||||||
|
|
||||||
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
_, _, hash, _ := api.ReadNote(s.brainDir, relPath)
|
||||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash}, nil
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Unlinked: unlinked}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update supersedes an existing note in place. slug may be a bare slug
|
// Update supersedes an existing note in place. slug may be a bare slug
|
||||||
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
// (resolved against n.Wing/n.Hall) or a full brain-relative path (when it
|
||||||
// contains a slash). It never creates — a missing target is an error.
|
// contains a slash). It never creates — a missing target is an error.
|
||||||
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (capture.Ref, error) {
|
||||||
|
content, unlinked, err := brain.StripUnresolvedLinks(s.brainDir, n.Content)
|
||||||
|
if err != nil {
|
||||||
|
return capture.Ref{}, err
|
||||||
|
}
|
||||||
|
n.Content = content
|
||||||
|
|
||||||
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason}
|
||||||
if strings.Contains(slug, "/") {
|
if strings.Contains(slug, "/") {
|
||||||
opts.Path = slug
|
opts.Path = slug
|
||||||
@@ -80,7 +92,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur
|
|||||||
}
|
}
|
||||||
s.indexInGraph(ctx, "brain_update", relPath)
|
s.indexInGraph(ctx, "brain_update", relPath)
|
||||||
|
|
||||||
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true}, nil
|
return capture.Ref{ID: relPath, Path: relPath, ContentHash: hash, Superseded: true, Unlinked: unlinked}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fetches a note by id/path — the read-after-write confirmation
|
// Get fetches a note by id/path — the read-after-write confirmation
|
||||||
|
|||||||
@@ -83,3 +83,45 @@ func TestStoreGetRoundTripsHash(t *testing.T) {
|
|||||||
assert.Equal(t, "a", note.Frontmatter["wing"])
|
assert.Equal(t, "a", note.Frontmatter["wing"])
|
||||||
assert.Contains(t, note.Body, "# Body")
|
assert.Contains(t, note.Body, "# Body")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreWriteUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "# Target\n\nbody\n", Filename: "target", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "See [[target]] and [[secret-hygiene]].\n",
|
||||||
|
Filename: "src", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"secret-hygiene"}, ref.Unlinked)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/src.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "[[target]]")
|
||||||
|
assert.Contains(t, string(got), "and secret-hygiene.")
|
||||||
|
assert.NotContains(t, string(got), "[[secret-hygiene]]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreUpdateUnwrapsUnresolvedWikilinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := brainstore.New(dir)
|
||||||
|
_, err := s.Write(context.Background(), capture.Note{
|
||||||
|
Content: "old\n", Filename: "n", Wing: "a", Hall: "facts",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ref, err := s.Update(context.Background(), "n", capture.Note{
|
||||||
|
Content: "Related: [[cad-babysit]].\n", Wing: "a", Hall: "facts", Reason: "r",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"cad-babysit"}, ref.Unlinked)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/a/facts/n.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Related: cad-babysit.")
|
||||||
|
assert.NotContains(t, string(got), "[[cad-babysit]]")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Package capture is the Clean-Architecture use-case for the uniform
|
// Package capture is the Clean-Architecture use-case for the uniform
|
||||||
// capture capability (issue #49/#51): persist a finished session's
|
// capture capability (issue #49/#51): persist a finished session's
|
||||||
// valuable output — insights → brain, action items → Gitea tickets,
|
// valuable output — insights → brain, action items → Gitea tickets —
|
||||||
// optional summary → ai-sessions — with one invocation, identical core
|
// with one invocation, identical core behaviour across every harness.
|
||||||
// behaviour across every harness.
|
|
||||||
//
|
//
|
||||||
// This package is pure orchestration. It depends only on ports
|
// This package is pure orchestration. It depends only on ports
|
||||||
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
||||||
@@ -83,19 +82,11 @@ type Ticket struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary is an optional session summary bound for ai-sessions.
|
|
||||||
type Summary struct {
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
ReposTouched []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptureInput is the whole capture request.
|
// CaptureInput is the whole capture request.
|
||||||
type CaptureInput struct {
|
type CaptureInput struct {
|
||||||
Context CaptureContext
|
Context CaptureContext
|
||||||
Insights []Insight
|
Insights []Insight
|
||||||
Tickets []Ticket
|
Tickets []Ticket
|
||||||
Summary *Summary
|
|
||||||
DryRun bool
|
DryRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,12 +108,6 @@ type TicketResult struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummaryResult is the summary outcome in the receipt.
|
|
||||||
type SummaryResult struct {
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ItemError pins a failure to a specific request item for the partial
|
// ItemError pins a failure to a specific request item for the partial
|
||||||
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
||||||
type ItemError struct {
|
type ItemError struct {
|
||||||
@@ -136,7 +121,6 @@ type ItemError struct {
|
|||||||
type CaptureReceipt struct {
|
type CaptureReceipt struct {
|
||||||
Insights []InsightResult `json:"insights"`
|
Insights []InsightResult `json:"insights"`
|
||||||
Tickets []TicketResult `json:"tickets"`
|
Tickets []TicketResult `json:"tickets"`
|
||||||
Summary *SummaryResult `json:"summary,omitempty"`
|
|
||||||
Errors []ItemError `json:"errors"`
|
Errors []ItemError `json:"errors"`
|
||||||
EffectiveClassification string `json:"effective_classification,omitempty"`
|
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type Ref struct {
|
|||||||
Path string
|
Path string
|
||||||
ContentHash string
|
ContentHash string
|
||||||
Superseded bool
|
Superseded bool
|
||||||
|
// Unlinked names the [[wikilink]] targets the store unwrapped to plain
|
||||||
|
// text because they resolve to no entry. Reported rather than rejected:
|
||||||
|
// see brain.StripUnresolvedLinks for why the write still lands.
|
||||||
|
Unlinked []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoredNote is a brain note fetched by Get: the read-after-write
|
// StoredNote is a brain note fetched by Get: the read-after-write
|
||||||
@@ -69,11 +73,6 @@ type IssueTracker interface {
|
|||||||
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummaryWriter is the ai-sessions summary port.
|
|
||||||
type SummaryWriter interface {
|
|
||||||
WriteFile(ctx context.Context, repo, path, content string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClassificationPolicy derives a target's sensitivity (model C). The
|
// ClassificationPolicy derives a target's sensitivity (model C). The
|
||||||
// "stricter wins" combination of declared vs derived is use-case policy
|
// "stricter wins" combination of declared vs derived is use-case policy
|
||||||
// and lives in the service, so the port stays minimal. Satisfied by
|
// and lives in the service, so the port stays minimal. Satisfied by
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package capture
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,20 +14,17 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
brain BrainStore
|
brain BrainStore
|
||||||
issues IssueTracker
|
issues IssueTracker
|
||||||
summaries SummaryWriter
|
|
||||||
policy ClassificationPolicy
|
policy ClassificationPolicy
|
||||||
audit AuditSink
|
audit AuditSink
|
||||||
|
|
||||||
// now is the clock, injectable for deterministic summary paths and
|
// now is the clock, injectable for deterministic audit timestamps in
|
||||||
// audit timestamps in tests.
|
// tests.
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a Service from its ports. summaries may be nil
|
// NewService constructs a Service from its ports.
|
||||||
// when no summary persistence is wired; a CaptureInput with a Summary
|
func NewService(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
// then fails that item rather than panicking.
|
return &Service{brain: b, issues: tr, policy: p, audit: a, now: time.Now}
|
||||||
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
|
||||||
return &Service{brain: b, issues: tr, summaries: sw, policy: p, audit: a, now: time.Now}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||||
@@ -88,7 +83,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
declared, _ = classification.ParseLevel(in.Context.Classification)
|
declared, _ = classification.ParseLevel(in.Context.Classification)
|
||||||
}
|
}
|
||||||
|
|
||||||
effective, securityEvents := s.resolveClassification(declared, in)
|
effective, securityEvents, escalators := s.resolveClassification(declared, in)
|
||||||
|
|
||||||
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
// Server-derived origin governs the I1 gate; a caller-asserted harness
|
||||||
// label that names a different zone is descriptive-only and logged as a
|
// label that names a different zone is descriptive-only and logged as a
|
||||||
@@ -112,8 +107,8 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
Items: nil, // refused before any write
|
Items: nil, // refused before any write
|
||||||
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
||||||
}, AuditCentral)
|
}, AuditCentral)
|
||||||
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin",
|
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin (%s)",
|
||||||
ErrSovereigntyRefused, in.Context.Origin)
|
ErrSovereigntyRefused, in.Context.Origin, escalationReason(escalators))
|
||||||
}
|
}
|
||||||
|
|
||||||
receipt := CaptureReceipt{
|
receipt := CaptureReceipt{
|
||||||
@@ -131,9 +126,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
for _, tk := range in.Tickets {
|
for _, tk := range in.Tickets {
|
||||||
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
||||||
}
|
}
|
||||||
if in.Summary != nil {
|
|
||||||
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
|
|
||||||
}
|
|
||||||
return receipt, nil
|
return receipt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,16 +161,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||||
}
|
}
|
||||||
|
|
||||||
if in.Summary != nil {
|
|
||||||
res, err := s.persistSummary(ctx, in.Context, in.Summary)
|
|
||||||
receipt.Summary = &res
|
|
||||||
if err != nil {
|
|
||||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
|
|
||||||
} else {
|
|
||||||
landed = append(landed, "summary:"+res.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// I5: persist the request-level audit record of exactly what landed,
|
// I5: persist the request-level audit record of exactly what landed,
|
||||||
// using the outcome reserved before the writes. AuditBuffered surfaces
|
// using the outcome reserved before the writes. AuditBuffered surfaces
|
||||||
// the degraded (locally-buffered) state on the receipt.
|
// the degraded (locally-buffered) state on the receipt.
|
||||||
@@ -239,15 +221,28 @@ func (s *Service) validate(in CaptureInput) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveClassification computes the effective level (stricter of
|
// resolveClassification computes the effective level (stricter of declared
|
||||||
// declared and every target's derived level) and collects a security
|
// and every target's derived level), collects a security event whenever the
|
||||||
// event whenever the caller under-declared relative to a target floor.
|
// caller under-declared relative to a target floor, and names the targets
|
||||||
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string) {
|
// that carry the strictest level.
|
||||||
|
//
|
||||||
|
// That third return is what makes an I1 refusal actionable. A caller sending
|
||||||
|
// twenty tickets gets one refusal; without the offending target it cannot
|
||||||
|
// tell which repo tripped the gate, and infra#265 shows what happens next —
|
||||||
|
// the caller concluded the tickets ARRAY was broken, built a fallback of
|
||||||
|
// twenty individual calls, and filed a bug against the wrong thing.
|
||||||
|
func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string, []string) {
|
||||||
effective := declared
|
effective := declared
|
||||||
var events []string
|
var events []string
|
||||||
|
derivedFor := map[string]classification.Level{}
|
||||||
|
var order []string
|
||||||
consider := func(kind classification.TargetKind, name string) {
|
consider := func(kind classification.TargetKind, name string) {
|
||||||
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
derived := s.policy.Derive(classification.Target{Kind: kind, Name: name})
|
||||||
effective = classification.Stricter(effective, derived)
|
effective = classification.Stricter(effective, derived)
|
||||||
|
if _, seen := derivedFor[name]; !seen {
|
||||||
|
derivedFor[name] = derived
|
||||||
|
order = append(order, name)
|
||||||
|
}
|
||||||
if declared < derived {
|
if declared < derived {
|
||||||
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s",
|
||||||
declared, name, kindString(kind), derived))
|
declared, name, kindString(kind), derived))
|
||||||
@@ -259,12 +254,28 @@ func (s *Service) resolveClassification(declared classification.Level, in Captur
|
|||||||
for _, tk := range in.Tickets {
|
for _, tk := range in.Tickets {
|
||||||
consider(classification.RepoTarget, tk.Repo)
|
consider(classification.RepoTarget, tk.Repo)
|
||||||
}
|
}
|
||||||
if in.Summary != nil {
|
|
||||||
for _, repo := range in.Summary.ReposTouched {
|
// Only targets that actually reach the effective level are named: at
|
||||||
consider(classification.RepoTarget, repo)
|
// twenty tickets, listing the innocent ones is noise that hides the one
|
||||||
|
// that matters.
|
||||||
|
var escalators []string
|
||||||
|
for _, name := range order {
|
||||||
|
if derivedFor[name] == effective && effective > declared {
|
||||||
|
escalators = append(escalators, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return effective, events
|
return effective, events, escalators
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalationReason renders why the effective classification landed where it
|
||||||
|
// did, for an operator reading a refusal. Naming the caller's own declaration
|
||||||
|
// matters as much as naming a target: "you asked for this" and "a repo you
|
||||||
|
// touched required it" call for different fixes.
|
||||||
|
func escalationReason(escalators []string) string {
|
||||||
|
if len(escalators) == 0 {
|
||||||
|
return "declared by the caller; no target required it"
|
||||||
|
}
|
||||||
|
return "required by: " + strings.Join(escalators, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) {
|
||||||
@@ -309,60 +320,6 @@ func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, e
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
|
|
||||||
if s.summaries == nil {
|
|
||||||
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
|
|
||||||
}
|
|
||||||
path := s.summaryPath(c, sum)
|
|
||||||
content := s.renderSummary(c, sum)
|
|
||||||
repo := "ai-sessions"
|
|
||||||
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
|
|
||||||
return SummaryResult{Path: path, OK: false}, err
|
|
||||||
}
|
|
||||||
return SummaryResult{Path: path, OK: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
|
|
||||||
// The ref8 disambiguator is derived from the session_ref (or the title
|
|
||||||
// when no ref is present) so distinct sessions never collide.
|
|
||||||
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
|
|
||||||
t := s.now().UTC()
|
|
||||||
slug := brain.Sanitise(sum.Title)
|
|
||||||
if slug == "" {
|
|
||||||
slug = "summary"
|
|
||||||
}
|
|
||||||
seed := c.SessionRef
|
|
||||||
if seed == "" {
|
|
||||||
seed = sum.Title + sum.Body
|
|
||||||
}
|
|
||||||
sum8 := shortHash(seed)
|
|
||||||
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
|
|
||||||
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderSummary stamps fidelity + session metadata into frontmatter so the
|
|
||||||
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
|
|
||||||
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("---\n")
|
|
||||||
fmt.Fprintf(&b, "title: %s\n", sum.Title)
|
|
||||||
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
|
|
||||||
if c.SessionRef != "" {
|
|
||||||
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
|
|
||||||
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
|
|
||||||
if len(sum.ReposTouched) > 0 {
|
|
||||||
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
|
|
||||||
}
|
|
||||||
b.WriteString("---\n\n")
|
|
||||||
b.WriteString(sum.Body)
|
|
||||||
if !strings.HasSuffix(sum.Body, "\n") {
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func kindString(k classification.TargetKind) string {
|
func kindString(k classification.TargetKind) string {
|
||||||
if k == classification.RepoTarget {
|
if k == classification.RepoTarget {
|
||||||
return "repo"
|
return "repo"
|
||||||
@@ -381,8 +338,3 @@ func firstLine(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortHash(s string) string {
|
|
||||||
sum := sha256.Sum256([]byte(s))
|
|
||||||
return hex.EncodeToString(sum[:])[:8]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package capture
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -59,12 +60,18 @@ type fakeTracker struct {
|
|||||||
closed []int
|
closed []int
|
||||||
comments []int
|
comments []int
|
||||||
err error
|
err error
|
||||||
|
// failOnTitle makes CreateIssue fail for one specific title, so a
|
||||||
|
// partial-failure receipt can be exercised without failing every item.
|
||||||
|
failOnTitle string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return IssueRef{}, f.err
|
return IssueRef{}, f.err
|
||||||
}
|
}
|
||||||
|
if f.failOnTitle != "" && title == f.failOnTitle {
|
||||||
|
return IssueRef{}, errors.New("tracker refused this one")
|
||||||
|
}
|
||||||
f.created = append(f.created, repo+":"+title)
|
f.created = append(f.created, repo+":"+title)
|
||||||
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil
|
||||||
}
|
}
|
||||||
@@ -85,24 +92,11 @@ func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _
|
|||||||
return IssueRef{Repo: repo, Number: number}, nil
|
return IssueRef{Repo: repo, Number: number}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeSummary struct {
|
|
||||||
paths []string
|
|
||||||
content []string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
|
||||||
if f.err != nil {
|
|
||||||
return f.err
|
|
||||||
}
|
|
||||||
f.paths = append(f.paths, path)
|
|
||||||
f.content = append(f.content, content)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakePolicy derives from an explicit map; default Internal so tests pin
|
// fakePolicy derives from an explicit map; default Internal so tests pin
|
||||||
// behaviour without depending on the real defaulting.
|
// behaviour without depending on the real defaulting.
|
||||||
type fakePolicy struct{ tags map[string]classification.Level }
|
type fakePolicy struct {
|
||||||
|
tags map[string]classification.Level
|
||||||
|
}
|
||||||
|
|
||||||
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
||||||
if lvl, ok := p.tags[t.Name]; ok {
|
if lvl, ok := p.tags[t.Name]; ok {
|
||||||
@@ -135,8 +129,8 @@ func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) erro
|
|||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
s := NewService(b, tr, sw, p, a)
|
s := NewService(b, tr, p, a)
|
||||||
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -151,7 +145,7 @@ func TestCaptureHappyPath(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -180,7 +174,7 @@ func TestCaptureHappyPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -197,7 +191,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -216,7 +210,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
||||||
@@ -239,7 +233,7 @@ func TestCapturePartialFailureBestEffort(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -264,7 +258,7 @@ func TestCaptureDryRunWritesNothing(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -287,7 +281,7 @@ func TestCaptureStricterClassificationWins(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(b, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "internal"
|
ctx.Classification = "internal"
|
||||||
@@ -306,7 +300,7 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
|||||||
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -319,26 +313,6 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
|||||||
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
|
||||||
sw := &fakeSummary{}
|
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
|
||||||
|
|
||||||
ctx := baseCtx()
|
|
||||||
ctx.Fidelity = "transcript-parse"
|
|
||||||
ctx.SessionRef = "abc123def456"
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
||||||
Context: ctx,
|
|
||||||
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, rec.Summary)
|
|
||||||
assert.True(t, rec.Summary.OK)
|
|
||||||
require.Len(t, sw.paths, 1)
|
|
||||||
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
|
||||||
assert.Contains(t, sw.paths[0], "session-wrap")
|
|
||||||
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- I1 sovereignty gate (#53) ---
|
// --- I1 sovereignty gate (#53) ---
|
||||||
|
|
||||||
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||||
@@ -346,7 +320,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
|||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, tr, nil, pol, au)
|
svc := newSvc(b, tr, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -368,7 +342,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
|||||||
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{})
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -387,7 +361,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
|||||||
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Harness = "sovereign-soil" // asserted
|
ctx.Harness = "sovereign-soil" // asserted
|
||||||
@@ -407,7 +381,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
|||||||
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||||
// us-nexus origin is fine for non-confidential data.
|
// us-nexus origin is fine for non-confidential data.
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
@@ -426,7 +400,7 @@ func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -443,7 +417,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
|||||||
// proceeds and the receipt flags the degraded audit state.
|
// proceeds and the receipt flags the degraded audit state.
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
au := &fakeAudit{reserveMode: AuditBuffered}
|
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au)
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -458,7 +432,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
|||||||
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||||
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||||
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -469,3 +443,98 @@ func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
|||||||
assert.True(t, rec.DryRun)
|
assert.True(t, rec.DryRun)
|
||||||
assert.Empty(t, au.entries)
|
assert.Empty(t, au.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- hyperguild#91 part 1 -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The reported symptom was "capture with a tickets array fails" (infra#265:
|
||||||
|
// 20 tickets errored, 5 errored, caller fell back to 20 individual
|
||||||
|
// issue_create calls). The array was never the failing dimension — every
|
||||||
|
// ticket targeted a client repo, and the I1 gate refuses that through a
|
||||||
|
// us-nexus origin at ANY length. The refusal did not say which target
|
||||||
|
// escalated the classification, so it read as a malfunction.
|
||||||
|
|
||||||
|
func TestCapture_TicketsArrayWorksAtLengthOne(t *testing.T) {
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "one", Body: "b"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Tickets, 1)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.Len(t, tr.created, 1)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_TicketsArrayWorksAtLengthMany(t *testing.T) {
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
in := CaptureInput{Context: baseCtx()}
|
||||||
|
for i := range 20 {
|
||||||
|
in.Tickets = append(in.Tickets, Ticket{
|
||||||
|
Repo: "hyperguild", Action: "create", Title: fmt.Sprintf("t%02d", i), Body: "b",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), in)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rec.Tickets, 20, "one receipt entry per ticket")
|
||||||
|
for i, r := range rec.Tickets {
|
||||||
|
assert.True(t, r.OK, "ticket %d failed: %+v", i, rec.Errors)
|
||||||
|
}
|
||||||
|
assert.Len(t, tr.created, 20)
|
||||||
|
assert.Empty(t, rec.Errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_OneFailingTicketDoesNotSinkTheRest(t *testing.T) {
|
||||||
|
// infra#265 asked explicitly: "if ticket 3 of 5 fails, what happened to
|
||||||
|
// 1 and 2?" They land, and the receipt says so per item.
|
||||||
|
b, au := &fakeBrain{}, &fakeAudit{}
|
||||||
|
tr := &fakeTracker{failOnTitle: "boom"}
|
||||||
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Tickets: []Ticket{
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "ok-1", Body: "b"},
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "boom", Body: "b"},
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "ok-3", Body: "b"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "a per-item failure is a partial receipt, not a call error")
|
||||||
|
require.Len(t, rec.Tickets, 3)
|
||||||
|
assert.True(t, rec.Tickets[0].OK)
|
||||||
|
assert.False(t, rec.Tickets[1].OK)
|
||||||
|
assert.True(t, rec.Tickets[2].OK)
|
||||||
|
require.Len(t, rec.Errors, 1)
|
||||||
|
assert.Contains(t, rec.Errors[0].Item, "ticket[1]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapture_I1RefusalNamesWhatEscalatedTheClassification(t *testing.T) {
|
||||||
|
// The actual defect behind infra#265. Without the offending target in
|
||||||
|
// the message, a caller with 20 tickets cannot tell which repo tripped
|
||||||
|
// the gate — and reasonably concludes the array itself is broken.
|
||||||
|
b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{}
|
||||||
|
pol := fakePolicy{tags: map[string]classification.Level{"trucker-ecommerce": classification.Confidential}}
|
||||||
|
svc := newSvc(b, tr, pol, au)
|
||||||
|
|
||||||
|
ctx := baseCtx()
|
||||||
|
ctx.Origin = ZoneUSNexus
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: ctx,
|
||||||
|
Tickets: []Ticket{
|
||||||
|
{Repo: "hyperguild", Action: "create", Title: "fine", Body: "b"},
|
||||||
|
{Repo: "trucker-ecommerce", Action: "create", Title: "client work", Body: "b"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, ErrSovereigntyRefused)
|
||||||
|
assert.Contains(t, err.Error(), "trucker-ecommerce",
|
||||||
|
"the refusal must name the target that escalated the classification")
|
||||||
|
assert.NotContains(t, err.Error(), "hyperguild",
|
||||||
|
"only the offending target, or the message is noise at 20 tickets")
|
||||||
|
assert.Empty(t, tr.created, "refused before any write")
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ type request struct {
|
|||||||
Context contextBody `json:"context"`
|
Context contextBody `json:"context"`
|
||||||
Insights []insightBody `json:"insights"`
|
Insights []insightBody `json:"insights"`
|
||||||
Tickets []ticketBody `json:"tickets"`
|
Tickets []ticketBody `json:"tickets"`
|
||||||
Summary *summaryBody `json:"summary,omitempty"`
|
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +81,6 @@ type ticketBody struct {
|
|||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type summaryBody struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
ReposTouched []string `json:"repos_touched,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||||
// result to an HTTP status.
|
// result to an HTTP status.
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -183,11 +176,6 @@ func (b request) toInput() capture.CaptureInput {
|
|||||||
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if b.Summary != nil {
|
|
||||||
in.Summary = &capture.Summary{
|
|
||||||
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +192,6 @@ func statusFor(rec capture.CaptureReceipt) int {
|
|||||||
for _, t := range rec.Tickets {
|
for _, t := range rec.Tickets {
|
||||||
count(&ok, &fail, t.OK)
|
count(&ok, &fail, t.OK)
|
||||||
}
|
}
|
||||||
if rec.Summary != nil {
|
|
||||||
count(&ok, &fail, rec.Summary.OK)
|
|
||||||
}
|
|
||||||
switch {
|
switch {
|
||||||
case fail == 0:
|
case fail == 0:
|
||||||
return http.StatusOK
|
return http.StatusOK
|
||||||
|
|||||||
@@ -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
|
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (fakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (fakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,7 @@ func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker,
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
cfg, err := classification.Load(t.TempDir())
|
cfg, err := classification.Load(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
|
svc := capture.NewService(brainstore.New(t.TempDir()), tr, cfg, audit.NewSlogSink(nil))
|
||||||
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ type refusingAudit struct{}
|
|||||||
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
func (refusingAudit) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||||
return 0, errors.New("central audit sink unreachable")
|
return 0, errors.New("central audit sink unreachable")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOutcome) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -190,7 +193,7 @@ func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOu
|
|||||||
func TestAuditUnavailableIs503(t *testing.T) {
|
func TestAuditUnavailableIs503(t *testing.T) {
|
||||||
cfg, err := classification.Load(t.TempDir())
|
cfg, err := classification.Load(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, nil, cfg, refusingAudit{})
|
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, cfg, refusingAudit{})
|
||||||
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
|
||||||
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Package claim validates the claim-property frontmatter that says when and
|
||||||
|
// where a note's claim holds, and who decided that (brain#21).
|
||||||
|
//
|
||||||
|
// Two families were designed together and land separately. This is the
|
||||||
|
// applicability envelope:
|
||||||
|
//
|
||||||
|
// half_life: volatile | seasonal | durable — how fast it decays
|
||||||
|
// applies: local | homelab | universal — how widely it holds
|
||||||
|
// claim_provenance: asserted | inferred — who set the above
|
||||||
|
//
|
||||||
|
// The strength family (confidence, contested) follows once the ingest-time
|
||||||
|
// contradiction detector exists to populate it.
|
||||||
|
//
|
||||||
|
// Why the envelope is validated rather than merely accepted: a value outside
|
||||||
|
// the enum is worse than an absent one. A lint skips what it cannot parse, so
|
||||||
|
// the note reads as classified while nothing acts on it — the field becomes
|
||||||
|
// decoration that suppresses the very review it was added to trigger.
|
||||||
|
//
|
||||||
|
// Why `applies` and not `scope`: `scope:` has been in use since brain#5 for
|
||||||
|
// glossary terms (`homelab-core`, `project:<repo>`) — a disjoint value space
|
||||||
|
// on the same key. The shipped schema keeps the name; this one takes another.
|
||||||
|
package claim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field names, exported so writers and lints agree on the spelling.
|
||||||
|
const (
|
||||||
|
FieldHalfLife = "half_life"
|
||||||
|
FieldApplies = "applies"
|
||||||
|
FieldProvenance = "claim_provenance"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
halfLives = []string{"volatile", "seasonal", "durable"}
|
||||||
|
appliesTo = []string{"local", "homelab", "universal"}
|
||||||
|
provenances = []string{"asserted", "inferred"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateEnvelope checks the envelope fields of one note's frontmatter.
|
||||||
|
//
|
||||||
|
// Absent fields are valid: the envelope is opt-in for new writes, and a
|
||||||
|
// corpus-wide backfill is deliberately out of scope (brain#21) — inferring
|
||||||
|
// 1113 notes' properties in bulk has the same failure mode as the extraction
|
||||||
|
// pass that produced 28 wrong wikilinks out of 67.
|
||||||
|
func ValidateEnvelope(fm map[string]string) error {
|
||||||
|
halfLife, hasHalfLife := present(fm, FieldHalfLife)
|
||||||
|
applies, hasApplies := present(fm, FieldApplies)
|
||||||
|
prov, hasProv := present(fm, FieldProvenance)
|
||||||
|
|
||||||
|
if hasHalfLife {
|
||||||
|
if err := oneOf(FieldHalfLife, halfLife, halfLives); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasApplies {
|
||||||
|
if err := oneOf(FieldApplies, applies, appliesTo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasProv {
|
||||||
|
if err := oneOf(FieldProvenance, prov, provenances); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field and its provenance travel together or not at all. A value
|
||||||
|
// whose author is unknown cannot be trusted differently from one Mathias
|
||||||
|
// asserted, which is how an inferred guess hardens into accepted fact.
|
||||||
|
switch {
|
||||||
|
case (hasHalfLife || hasApplies) && !hasProv:
|
||||||
|
return fmt.Errorf("%s is required when %s or %s is set",
|
||||||
|
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||||
|
case hasProv && !hasHalfLife && !hasApplies:
|
||||||
|
return fmt.Errorf("%s is set but neither %s nor %s is",
|
||||||
|
FieldProvenance, FieldHalfLife, FieldApplies)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// present reports a trimmed field value and whether it was set to anything
|
||||||
|
// non-empty. A key written with an empty value is treated as absent rather
|
||||||
|
// than as an error: that is a caller emitting a placeholder, not asserting.
|
||||||
|
func present(fm map[string]string, key string) (string, bool) {
|
||||||
|
v := strings.TrimSpace(fm[key])
|
||||||
|
return v, v != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneOf(field, value string, allowed []string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == value {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %q is not one of %s", field, value, strings.Join(allowed, ", "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Package claim_test covers the claim-property envelope (brain#21).
|
||||||
|
package claim_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEnvelope(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fm map[string]string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "absent is fine — the fields are opt-in on new writes",
|
||||||
|
fm: map[string]string{"wing": "homelab", "hall": "facts"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete envelope with provenance",
|
||||||
|
fm: map[string]string{
|
||||||
|
"half_life": "durable", "applies": "universal", "claim_provenance": "asserted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inferred provenance is equally valid — it just must be stated",
|
||||||
|
fm: map[string]string{
|
||||||
|
"half_life": "volatile", "applies": "local", "claim_provenance": "inferred",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A value nobody can act on is worse than no value: a lint
|
||||||
|
// silently skips it, so the note looks classified and isn't.
|
||||||
|
name: "unknown half_life value",
|
||||||
|
fm: map[string]string{"half_life": "forever", "claim_provenance": "asserted"},
|
||||||
|
wantErr: `half_life: "forever" is not one of volatile, seasonal, durable`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown applies value",
|
||||||
|
fm: map[string]string{"applies": "galaxy", "claim_provenance": "asserted"},
|
||||||
|
wantErr: `applies: "galaxy" is not one of local, homelab, universal`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// brain#21: a value a model inferred must never be
|
||||||
|
// indistinguishable from one Mathias asserted. Writing the field
|
||||||
|
// without saying who set it is exactly that laundering.
|
||||||
|
name: "envelope field without provenance",
|
||||||
|
fm: map[string]string{"half_life": "durable"},
|
||||||
|
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "applies without provenance",
|
||||||
|
fm: map[string]string{"applies": "homelab"},
|
||||||
|
wantErr: "claim_provenance is required when half_life or applies is set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown provenance value",
|
||||||
|
fm: map[string]string{"half_life": "durable", "claim_provenance": "probably"},
|
||||||
|
wantErr: `claim_provenance: "probably" is not one of asserted, inferred`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Provenance alone says who set fields that aren't there.
|
||||||
|
name: "provenance without any envelope field",
|
||||||
|
fm: map[string]string{"claim_provenance": "asserted"},
|
||||||
|
wantErr: "claim_provenance is set but neither half_life nor applies is",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The glossary wing has used scope: homelab-core since #5. The
|
||||||
|
// envelope deliberately does NOT claim that key (brain#21).
|
||||||
|
name: "glossary scope is untouched",
|
||||||
|
fm: map[string]string{"scope": "homelab-core", "term": "koala"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := claim.ValidateEnvelope(tt.fm)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
Sink Sink
|
||||||
Cursors *CursorStore
|
Cursors *CursorStore
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
|
||||||
|
// SkipProjects excludes whole project directories from ingestion, as
|
||||||
|
// filepath.Match globs tested against the project directory NAME (the
|
||||||
|
// basename, e.g. "-home-mathias-dev"), not the full path.
|
||||||
|
//
|
||||||
|
// Globs rather than exact names because a project directory is derived
|
||||||
|
// from the session's working directory, so a git worktree yields an
|
||||||
|
// ephemeral name like
|
||||||
|
// "-home-mathias-dev--claude-worktrees-bridge-cse-017xoC..." that no
|
||||||
|
// exact-match list could keep up with.
|
||||||
|
//
|
||||||
|
// Matching directories are skipped during the directory WALK, so their
|
||||||
|
// files are never opened and their content never enters this process —
|
||||||
|
// which is a stronger property than filtering it out afterwards.
|
||||||
|
//
|
||||||
|
// An unparseable pattern stops the watcher. A silently dropped skip rule
|
||||||
|
// means ingesting what was meant to be excluded, and that is not
|
||||||
|
// noticeable after the fact.
|
||||||
|
SkipProjects []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
// Watch runs the polling loop until ctx is cancelled. Returns ctx.Err()
|
||||||
@@ -68,6 +87,9 @@ func Watch(ctx context.Context, cfg Config) error {
|
|||||||
if cfg.Logger == nil {
|
if cfg.Logger == nil {
|
||||||
cfg.Logger = slog.Default()
|
cfg.Logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
cfg.Logger.Info("claudewatcher: started",
|
cfg.Logger.Info("claudewatcher: started",
|
||||||
"sessions_dir", cfg.SessionsDir,
|
"sessions_dir", cfg.SessionsDir,
|
||||||
"host", cfg.Host,
|
"host", cfg.Host,
|
||||||
@@ -91,7 +113,7 @@ func Watch(ctx context.Context, cfg Config) error {
|
|||||||
// runTick is one polling pass. Exposed (lowercase) for tests via
|
// runTick is one polling pass. Exposed (lowercase) for tests via
|
||||||
// TickOnce.
|
// TickOnce.
|
||||||
func runTick(ctx context.Context, cfg Config) {
|
func runTick(ctx context.Context, cfg Config) {
|
||||||
files, err := listSessionFiles(cfg.SessionsDir)
|
files, err := listSessionFiles(cfg.SessionsDir, cfg.SkipProjects, cfg.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
cfg.Logger.Warn("claudewatcher: list session files", "err", err)
|
||||||
return
|
return
|
||||||
@@ -119,17 +141,52 @@ func TickOnce(ctx context.Context, cfg Config) error {
|
|||||||
if cfg.Logger == nil {
|
if cfg.Logger == nil {
|
||||||
cfg.Logger = slog.Default()
|
cfg.Logger = slog.Default()
|
||||||
}
|
}
|
||||||
|
if err := validateSkipPatterns(cfg.SkipProjects); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
runTick(ctx, cfg)
|
runTick(ctx, cfg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
var out []string
|
||||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
|
// SkipDir, so nothing inside is even listed. The files are never
|
||||||
|
// opened and their content never reaches this process.
|
||||||
|
if path != root && skipProject(d.Name(), skip) {
|
||||||
|
log.Info("claudewatcher: project skipped by configuration", "project", d.Name())
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(path, ".jsonl") {
|
if !strings.HasSuffix(path, ".jsonl") {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Package frontmatter lints and repairs the YAML frontmatter block of a
|
||||||
|
// markdown note.
|
||||||
|
//
|
||||||
|
// Notes written directly — hand-authored, or promoted from brain/raw/ — do not
|
||||||
|
// pass through pipeline.BuildPages, so nothing quotes their scalars. A title
|
||||||
|
// containing an unescaped ": " then produces a block that yaml.Unmarshal
|
||||||
|
// rejects with "mapping values are not allowed here", and every consumer that
|
||||||
|
// reads frontmatter silently skips the note.
|
||||||
|
//
|
||||||
|
// Fix is deliberately conservative: it only adds quoting, never reorders or
|
||||||
|
// rewrites, and it refuses to return a document that still does not parse.
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fence = "---"
|
||||||
|
|
||||||
|
// Lint reports whether doc's frontmatter block parses as YAML.
|
||||||
|
//
|
||||||
|
// A document with no frontmatter block, or with an unterminated one, is not an
|
||||||
|
// error: there is nothing claiming to be YAML.
|
||||||
|
func Lint(doc []byte) error {
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(b, &v); err != nil {
|
||||||
|
return fmt.Errorf("frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix quotes unquoted scalars whose value breaks YAML parsing and returns the
|
||||||
|
// repaired document.
|
||||||
|
//
|
||||||
|
// It returns (doc, false, nil) when the document already parses, and
|
||||||
|
// (doc, false, err) when quoting cannot rescue it — an unfixable document is
|
||||||
|
// always returned byte-identical, never half-repaired.
|
||||||
|
func Fix(doc []byte) ([]byte, bool, error) {
|
||||||
|
if err := Lint(doc); err == nil {
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
// Lint only fails when there is a block, so this is unreachable in
|
||||||
|
// practice; return the original rather than guess.
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed := quoteScalars(b)
|
||||||
|
if bytes.Equal(fixed, b) {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: not repairable by quoting: %w", Lint(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(fixed, &v); err != nil {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rest, _ := split(doc)
|
||||||
|
out := []byte(fence + "\n")
|
||||||
|
out = append(out, fixed...)
|
||||||
|
out = append(out, []byte(fence)...)
|
||||||
|
out = append(out, rest...)
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mappingLine matches "key: value" at any indentation, capturing the prefix so
|
||||||
|
// it can be re-emitted untouched.
|
||||||
|
//
|
||||||
|
// Sequence items ("- value") are deliberately not handled: "- foo: bar" is
|
||||||
|
// valid YAML, so a document containing one does not reach Fix in the first
|
||||||
|
// place. See the Lint table for why that gap is left open.
|
||||||
|
var mappingLine = regexp.MustCompile(`^(\s*[A-Za-z_][\w.-]*:[ \t]+)(\S.*)$`)
|
||||||
|
|
||||||
|
// quoteScalars single-quotes every unquoted scalar value in the block that
|
||||||
|
// contains a colon. Values that are already quoted, or that carry YAML syntax
|
||||||
|
// this package has no business rewriting, are left alone.
|
||||||
|
func quoteScalars(block []byte) []byte {
|
||||||
|
lines := strings.Split(string(block), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
m := mappingLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix, value := m[1], m[2]
|
||||||
|
if !needsQuoting(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines[i] = prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsQuoting reports whether a scalar value has to be quoted to load as the
|
||||||
|
// string it plainly is.
|
||||||
|
//
|
||||||
|
// The test is YAML's own parser rather than a pattern: ask whether the value
|
||||||
|
// stands alone as the right-hand side of a mapping. Heuristics get this wrong
|
||||||
|
// in both directions — `title: "Foo" — bar` opens with a quote but is not
|
||||||
|
// quoted, and a value may contain a colon inside quotes and be perfectly fine.
|
||||||
|
//
|
||||||
|
// Anything that already parses is left alone, whatever it parses into — a flow
|
||||||
|
// collection, a number, a bool. It is not broken, and quoting would change its
|
||||||
|
// type.
|
||||||
|
func needsQuoting(value string) bool {
|
||||||
|
if strings.HasPrefix(value, "|") || strings.HasPrefix(value, ">") {
|
||||||
|
return false // block scalar; its content is not parsed as YAML
|
||||||
|
}
|
||||||
|
var probe map[string]any
|
||||||
|
return yaml.Unmarshal([]byte("v: "+value+"\n"), &probe) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// block returns the raw YAML between the opening and closing fences.
|
||||||
|
func block(doc []byte) ([]byte, bool) {
|
||||||
|
b, _, ok := split(doc)
|
||||||
|
return b, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// split returns the frontmatter block and everything from the closing fence
|
||||||
|
// onward (the closing fence itself excluded, its trailing newline included).
|
||||||
|
func split(doc []byte) (block, rest []byte, ok bool) {
|
||||||
|
s := string(doc)
|
||||||
|
if !strings.HasPrefix(s, fence+"\n") {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
body := s[len(fence)+1:]
|
||||||
|
|
||||||
|
idx := strings.Index(body, "\n"+fence)
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// ingestion/internal/frontmatter/frontmatter_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no frontmatter block is not an error",
|
||||||
|
doc: "# Just a heading\n\nSome body text.\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted title parses",
|
||||||
|
doc: "---\ntitle: 'Headless dispatch runs: Bash is denied'\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain title parses",
|
||||||
|
doc: "---\ntitle: A simple title\nwing: homelab\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted colon in title does not parse",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Known gap, deliberately not fixed here: this parses, but the
|
||||||
|
// item becomes map[string]any{"Headless dispatch": "denied"}
|
||||||
|
// rather than a string. It is a silent type corruption, not a
|
||||||
|
// parse failure, so repairing it would rewrite documents that
|
||||||
|
// currently load — out of scope for a lint about broken YAML.
|
||||||
|
name: "unquoted colon in a list item parses as a mapping, not an error",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated frontmatter is not treated as frontmatter",
|
||||||
|
doc: "---\ntitle: no closing fence\n\nbody\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := Lint([]byte(tt.doc))
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantChanged bool
|
||||||
|
wantKey string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "quotes an unquoted title containing a colon",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Headless dispatch runs: Bash is denied — use gitea MCP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Parses already (see the Lint table), so Fix must leave it alone
|
||||||
|
// rather than rewrite a document that loads.
|
||||||
|
name: "leaves a colon-bearing list item alone because it already parses",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escapes an embedded single quote when quoting",
|
||||||
|
doc: "---\ntitle: Don't do this: it breaks\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Don't do this: it breaks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The value opens with a double quote and then keeps going after
|
||||||
|
// the closing one, so YAML reads it as a quoted scalar followed by
|
||||||
|
// junk. Treating "starts with a quote" as "already quoted" leaves
|
||||||
|
// this broken forever.
|
||||||
|
name: "quotes a value that only looks quoted",
|
||||||
|
doc: "---\ntitle: \"Evaluation Driven Generation\" — a TDD-style scaffold\ntags: [a, b]\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "\"Evaluation Driven Generation\" — a TDD-style scaffold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves an already-valid document untouched",
|
||||||
|
doc: "---\ntitle: 'Already quoted: fine'\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Already quoted: fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves a document with no frontmatter untouched",
|
||||||
|
doc: "# Heading\n\nbody: with a colon\n",
|
||||||
|
wantChanged: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, changed, err := Fix([]byte(tt.doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantChanged, changed)
|
||||||
|
|
||||||
|
if !tt.wantChanged {
|
||||||
|
assert.Equal(t, tt.doc, string(got), "unchanged docs must be returned byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever came back must parse — that is the whole point.
|
||||||
|
require.NoError(t, Lint(got), "Fix must not return a document that still fails to parse")
|
||||||
|
|
||||||
|
if tt.wantKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block, ok := block(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal(block, &m))
|
||||||
|
assert.Equal(t, tt.wantValue, m[tt.wantKey], "the value must survive quoting unchanged")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_LeavesStructuredValuesStructured(t *testing.T) {
|
||||||
|
// Only the broken scalar may be touched. A flow sequence on a neighbouring
|
||||||
|
// line still has to load as a sequence afterwards, not as a string.
|
||||||
|
doc := "---\ntitle: Broken: here\ntags: [prompting, llm]\ncount: 3\n---\n\nbody\n"
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
b, ok := block(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal(b, &m))
|
||||||
|
|
||||||
|
assert.Equal(t, "Broken: here", m["title"])
|
||||||
|
assert.Equal(t, []any{"prompting", "llm"}, m["tags"])
|
||||||
|
assert.Equal(t, 3, m["count"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_PreservesBodyExactly(t *testing.T) {
|
||||||
|
body := "\nThe body has: colons, 'quotes', and\n---\na horizontal rule.\n"
|
||||||
|
doc := "---\ntitle: Broken: title\n---" + body
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
assert.True(t, len(got) > 0)
|
||||||
|
|
||||||
|
_, rest, ok := split([]byte(doc))
|
||||||
|
require.True(t, ok)
|
||||||
|
_, gotRest, ok := split(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
|
||||||
|
// A frontmatter block that quoting cannot rescue: the structure itself is
|
||||||
|
// broken, not a scalar. Fix must return the original and an error rather
|
||||||
|
// than a half-repaired document.
|
||||||
|
doc := "---\ntitle: fine\n\tbad: tab-indented mapping\n - stray list item\n---\n\nbody\n"
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, changed)
|
||||||
|
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding is one markdown file whose frontmatter does not parse.
|
||||||
|
type Finding struct {
|
||||||
|
Path string // relative to the scanned root
|
||||||
|
Err error // why it does not parse, or why the repair was refused
|
||||||
|
Repaired bool // true only when fix was requested and the file was rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan walks root for *.md files whose frontmatter fails to parse.
|
||||||
|
//
|
||||||
|
// With fix set, each finding that quoting can rescue is rewritten in place;
|
||||||
|
// anything quoting cannot rescue is reported and left byte-identical. A file
|
||||||
|
// that cannot be read or decoded is reported as a finding rather than aborting
|
||||||
|
// the walk — one poisoned note must not hide the other findings.
|
||||||
|
func Scan(root string, fix bool) ([]Finding, error) {
|
||||||
|
var found []Finding
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, relErr := filepath.Rel(root, path)
|
||||||
|
if relErr != nil {
|
||||||
|
rel = path
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: readErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !utf8.Valid(doc) {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fmt.Errorf("not valid UTF-8")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if lintErr := Lint(doc); lintErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fix {
|
||||||
|
found = append(found, Finding{Path: rel, Err: Lint(doc)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired, changed, fixErr := Fix(doc)
|
||||||
|
if fixErr != nil || !changed {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fixErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if writeErr := writeFilePreservingMode(path, repaired); writeErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: writeErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found = append(found, Finding{Path: rel, Repaired: true})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan %s: %w", root, err)
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFilePreservingMode(path string, content []byte) error {
|
||||||
|
mode := fs.FileMode(0o644)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, content, mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTree(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(root, name)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
brokenDoc = "---\ntitle: Broken: title here\nwing: homelab\n---\n\nbody\n"
|
||||||
|
validDoc = "---\ntitle: 'Fine: quoted'\n---\n\nbody\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScan_ReportsOnlyBrokenFiles(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
"wiki/b/plain.md": "# no frontmatter\n\nbody: with colon\n",
|
||||||
|
"wiki/a/notes.txt": brokenDoc, // not markdown — must be ignored
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/a/broken.md", found[0].Path)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
// Dry run must not touch the file.
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, brokenDoc, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_FixWritesRepairedFilesAndLeavesTheRestAlone(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.True(t, found[0].Repaired)
|
||||||
|
assert.NoError(t, found[0].Err)
|
||||||
|
|
||||||
|
repaired, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, Lint(repaired), "the repaired file must parse")
|
||||||
|
assert.Contains(t, string(repaired), "title: 'Broken: title here'")
|
||||||
|
assert.Contains(t, string(repaired), "wing: homelab", "untouched keys must survive")
|
||||||
|
|
||||||
|
untouched, err := os.ReadFile(filepath.Join(root, "wiki/a/valid.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, validDoc, string(untouched))
|
||||||
|
|
||||||
|
// Second pass finds nothing — the repair is idempotent and complete.
|
||||||
|
again, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUnfixableWithoutWriting(t *testing.T) {
|
||||||
|
unfixable := "---\ntitle: fine\n\tbad: tab-indented\n - stray\n---\n\nbody\n"
|
||||||
|
root := writeTree(t, map[string]string{"wiki/x.md": unfixable})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, unfixable, string(got), "an unfixable file must be left exactly as it was")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUndecodableFilesInsteadOfFailingTheRun(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{"wiki/ok.md": validDoc})
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "wiki/bad-bytes.md"),
|
||||||
|
[]byte{'-', '-', '-', '\n', 0xff, 0xfe, '\n', '-', '-', '-', '\n'}, 0o644))
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err, "one unreadable file must not abort the whole scan")
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/bad-bytes.md", found[0].Path)
|
||||||
|
}
|
||||||
@@ -5,14 +5,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router calls Primary first; on any error falls back to Fallback.
|
// Router calls Primary first; on any error falls back to Fallback, then to
|
||||||
// Fallback may be nil, in which case primary errors are returned directly.
|
// 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 {
|
type Router struct {
|
||||||
Primary *Client
|
Primary *Client
|
||||||
Fallback *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) {
|
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||||
out, err := r.Primary.Complete(ctx, system, user)
|
out, err := r.Primary.Complete(ctx, system, user)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -22,8 +26,15 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
|
|||||||
return "", fmt.Errorf("primary llm: %w", err)
|
return "", fmt.Errorf("primary llm: %w", err)
|
||||||
}
|
}
|
||||||
out, err2 := r.Fallback.Complete(ctx, system, user)
|
out, err2 := r.Fallback.Complete(ctx, system, user)
|
||||||
if err2 != nil {
|
if err2 == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if r.Tertiary == nil {
|
||||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
||||||
}
|
}
|
||||||
|
out, err3 := r.Tertiary.Complete(ctx, system, user)
|
||||||
|
if err3 != nil {
|
||||||
|
return "", fmt.Errorf("primary llm: %w; fallback llm: %v; tertiary llm: %v", err, err2, err3)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,3 +69,78 @@ func TestRouter_NilFallback(t *testing.T) {
|
|||||||
_, err := r.Complete(context.Background(), "sys", "user")
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
tertiary := mockServer(t, "from-tertiary")
|
||||||
|
defer tertiary.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fail.URL, "", "m", time.Second),
|
||||||
|
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
out, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "from-tertiary", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_TertiaryNotCalledWhenFallbackSucceeds(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
fallback := mockServer(t, "from-fallback")
|
||||||
|
defer fallback.Close()
|
||||||
|
tertiary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("tertiary must not be called when fallback succeeds")
|
||||||
|
}))
|
||||||
|
defer tertiary.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||||
|
Tertiary: New(tertiary.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
out, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "from-fallback", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_AllThreeFail(t *testing.T) {
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "err", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fail.URL, "", "m", time.Second),
|
||||||
|
Tertiary: New(fail.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "primary llm")
|
||||||
|
assert.Contains(t, err.Error(), "fallback llm")
|
||||||
|
assert.Contains(t, err.Error(), "tertiary llm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_NilTertiary(t *testing.T) {
|
||||||
|
// Existing 2-tier deployments (no BRAIN_LLM_TERTIARY_URL) must keep working
|
||||||
|
// exactly as before — Tertiary is opt-in, not required.
|
||||||
|
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "err", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer fail.Close()
|
||||||
|
|
||||||
|
r := &Router{
|
||||||
|
Primary: New(fail.URL, "", "m", time.Second),
|
||||||
|
Fallback: New(fail.URL, "", "m", time.Second),
|
||||||
|
}
|
||||||
|
_, err := r.Complete(context.Background(), "sys", "user")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.NotContains(t, err.Error(), "tertiary")
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||||
@@ -97,6 +98,8 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
noteToolDescriptors()[0],
|
||||||
|
noteToolDescriptors()[1],
|
||||||
{
|
{
|
||||||
"name": "brain_tunnel",
|
"name": "brain_tunnel",
|
||||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||||
@@ -105,13 +108,6 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"target": str("path of target note (must be in a different wing)"),
|
"target": str("path of target note (must be in a different wing)"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "brain_index",
|
|
||||||
"description": "Regenerate _index.md (Map of Content) for one or all wings under brain/wiki/. Auto-called after brain_write with wing+hall.",
|
|
||||||
"inputSchema": schema([]string{}, map[string]any{
|
|
||||||
"wing": str("optional wing to index; if absent, rebuilds every wing"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "brain_ingest_raw",
|
"name": "brain_ingest_raw",
|
||||||
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
|
"description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.",
|
||||||
@@ -237,6 +233,15 @@ type brainWriteArgs struct {
|
|||||||
Hall string `json:"hall,omitempty"`
|
Hall string `json:"hall,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unlinkedNote explains the `unlinked` field to the caller that caused it.
|
||||||
|
// The write landed; what is gone is the [[...]] around a target that names no
|
||||||
|
// brain entry. Said here rather than only in a doc comment because the caller
|
||||||
|
// is a model that will otherwise repeat it on the next write.
|
||||||
|
const unlinkedNote = "these [[wikilink]] targets resolve to no brain entry, " +
|
||||||
|
"so they were unwrapped to plain text. Link only entries that exist " +
|
||||||
|
"(a filename under knowledge/ or wiki/); write the target entry first if " +
|
||||||
|
"you want the link, and keep skills, repos and agent-local memory files as prose."
|
||||||
|
|
||||||
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
var a brainWriteArgs
|
var a brainWriteArgs
|
||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
@@ -257,7 +262,12 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return json.Marshal(map[string]string{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash})
|
out := map[string]any{"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash}
|
||||||
|
if len(ref.Unlinked) > 0 {
|
||||||
|
out["unlinked"] = ref.Unlinked
|
||||||
|
out["unlinked_note"] = unlinkedNote
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainUpdateArgs struct {
|
type brainUpdateArgs struct {
|
||||||
@@ -301,9 +311,14 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return json.Marshal(map[string]any{
|
out := map[string]any{
|
||||||
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
"id": ref.ID, "path": ref.Path, "content_hash": ref.ContentHash, "superseded": ref.Superseded,
|
||||||
})
|
}
|
||||||
|
if len(ref.Unlinked) > 0 {
|
||||||
|
out["unlinked"] = ref.Unlinked
|
||||||
|
out["unlinked_note"] = unlinkedNote
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
type brainGetArgs struct {
|
type brainGetArgs struct {
|
||||||
@@ -327,6 +342,15 @@ func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMe
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
return nil, fmt.Errorf("id or path is required")
|
return nil, fmt.Errorf("id or path is required")
|
||||||
}
|
}
|
||||||
|
// Containment: the store resolves any brain-relative path, and
|
||||||
|
// parseFrontmatter returns the WHOLE file as body when there is no
|
||||||
|
// frontmatter fence — so without this, brain_get reads verbatim session
|
||||||
|
// transcripts under archive/ and sessions/, which BM25 never indexes and
|
||||||
|
// which nobody therefore thinks of as reachable. Same allowlist as
|
||||||
|
// brain_read (brain#4).
|
||||||
|
if _, err := notes.Resolve(s.brainDir, target, notes.ScopeNotes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
note, err := s.store.Get(ctx, target)
|
note, err := s.store.Get(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pendingNudgeThreshold is how old a raw/ note has to be before brain_context
|
||||||
|
// mentions the queue. Two weeks: long enough that a note written this sprint
|
||||||
|
// does not nag, short enough that four items cannot sit unnoticed for three
|
||||||
|
// months again (#82).
|
||||||
|
const pendingNudgeThreshold = 14 * 24 * time.Hour
|
||||||
|
|
||||||
|
// PendingNudge is the in-session reminder that the promote/reject queue has
|
||||||
|
// items waiting. It is advisory: a count, an age, and how to look.
|
||||||
|
type PendingNudge struct {
|
||||||
|
Pending int `json:"pending"` // everything in the queue
|
||||||
|
Aging int `json:"aging"` // those past the threshold
|
||||||
|
Oldest string `json:"oldest"` // filename of the oldest pending note
|
||||||
|
OldestDays int `json:"oldest_days"` // its age, in whole days
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pendingNudge summarises the raw/ review queue, or returns nil when there is
|
||||||
|
// nothing worth saying.
|
||||||
|
//
|
||||||
|
// The trigger is the missing half of the queue (#82): brain_pending has always
|
||||||
|
// existed, and four real items still sat unreviewed from 2026-04-23 to
|
||||||
|
// 2026-07-15 because nothing ever prompted anyone to call it. The system
|
||||||
|
// should be what remembers the queue exists.
|
||||||
|
//
|
||||||
|
// Threshold-gated on purpose. A notice on every context call is one people
|
||||||
|
// learn to skip, which reproduces the original failure with extra steps.
|
||||||
|
//
|
||||||
|
// It never promotes, rejects, or reorders anything. Age is a reason to ask a
|
||||||
|
// human, not a verdict — an old note may be exactly right to promote once
|
||||||
|
// someone finally looks at it.
|
||||||
|
func pendingNudge(brainDir string, now time.Time, threshold time.Duration) *PendingNudge {
|
||||||
|
pending, err := api.ListPending(brainDir)
|
||||||
|
if err != nil || len(pending) == 0 {
|
||||||
|
// brain_context is a hot path; a broken queue degrades to silence
|
||||||
|
// rather than failing the call that asked for project context.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var aging int
|
||||||
|
var oldestName string
|
||||||
|
var oldestAge time.Duration
|
||||||
|
|
||||||
|
for _, p := range pending {
|
||||||
|
created, perr := time.Parse(time.RFC3339, p.CreatedAt)
|
||||||
|
if perr != nil {
|
||||||
|
continue // an unparseable timestamp is not evidence of age
|
||||||
|
}
|
||||||
|
age := now.Sub(created)
|
||||||
|
if age >= threshold {
|
||||||
|
aging++
|
||||||
|
}
|
||||||
|
if age > oldestAge {
|
||||||
|
oldestAge, oldestName = age, p.Filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if aging == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PendingNudge{
|
||||||
|
Pending: len(pending),
|
||||||
|
Aging: aging,
|
||||||
|
Oldest: oldestName,
|
||||||
|
OldestDays: int(oldestAge.Hours() / 24),
|
||||||
|
Hint: "call brain_pending to review; nothing is promoted or rejected automatically",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writePending drops a raw/ note whose created_at is age old.
|
||||||
|
func writePending(t *testing.T, brainDir, name string, age time.Duration, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||||
|
body := "---\ncreated_at: " + now.Add(-age).UTC().Format(time.RFC3339) + "\n---\n\nnote body\n"
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, name), []byte(body), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_SilentWhenNothingIsPending(t *testing.T) {
|
||||||
|
// No queue, no nudge. A notice that fires every session is one people
|
||||||
|
// learn to skip, which is how the queue got ignored for three months in
|
||||||
|
// the first place (#82).
|
||||||
|
got := pendingNudge(t.TempDir(), time.Now(), 14*24*time.Hour)
|
||||||
|
assert.Nil(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_SilentWhileEverythingIsBelowTheThreshold(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
dir := t.TempDir()
|
||||||
|
writePending(t, dir, "fresh.md", 2*24*time.Hour, now)
|
||||||
|
|
||||||
|
got := pendingNudge(dir, now, 14*24*time.Hour)
|
||||||
|
assert.Nil(t, got, "a two-day-old note is not aging")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_ReportsCountAndOldestAgeOnceSomethingAges(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
dir := t.TempDir()
|
||||||
|
writePending(t, dir, "old.md", 100*24*time.Hour, now)
|
||||||
|
writePending(t, dir, "older.md", 120*24*time.Hour, now)
|
||||||
|
writePending(t, dir, "fresh.md", 1*24*time.Hour, now)
|
||||||
|
|
||||||
|
got := pendingNudge(dir, now, 14*24*time.Hour)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
|
||||||
|
// All three are pending; the threshold decides whether to speak, not
|
||||||
|
// what to count. Someone opening the queue sees the whole queue.
|
||||||
|
assert.Equal(t, 3, got.Pending)
|
||||||
|
assert.Equal(t, 2, got.Aging)
|
||||||
|
assert.Equal(t, 120, got.OldestDays)
|
||||||
|
assert.Equal(t, "older.md", got.Oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_NeverDecidesAnything(t *testing.T) {
|
||||||
|
// Aging is a trigger to ask a human, never a verdict. The nudge must
|
||||||
|
// carry no action and must leave the queue exactly as it found it.
|
||||||
|
now := time.Now()
|
||||||
|
dir := t.TempDir()
|
||||||
|
writePending(t, dir, "ancient.md", 365*24*time.Hour, now)
|
||||||
|
|
||||||
|
before, err := os.ReadDir(filepath.Join(dir, "raw"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := pendingNudge(dir, now, 14*24*time.Hour)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
|
||||||
|
after, err := os.ReadDir(filepath.Join(dir, "raw"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, after, len(before), "the nudge must not promote or reject anything")
|
||||||
|
assert.Contains(t, got.Hint, "brain_pending", "the nudge should say how to look, not act")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNudge_SurvivesAnUnreadableQueue(t *testing.T) {
|
||||||
|
// brain_context is a hot path; a broken raw/ dir must degrade to silence
|
||||||
|
// rather than failing the whole context call.
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw"), []byte("not a directory"), 0o644))
|
||||||
|
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
assert.Nil(t, pendingNudge(dir, time.Now(), 14*24*time.Hour))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainContext_CarriesTheNudgeWithoutFailingWhenTheQueueIsEmpty(t *testing.T) {
|
||||||
|
// The wiring, not the summary: a nudge that never reaches a caller is the
|
||||||
|
// same as no nudge, and brain_context must still work with no queue.
|
||||||
|
dir := t.TempDir()
|
||||||
|
srv := NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
raw, err := srv.brainContext(context.Background(),
|
||||||
|
json.RawMessage(`{"project_root":"/home/mathias/dev/AGENTS/hyperguild"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var empty map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &empty))
|
||||||
|
assert.NotContains(t, empty, "pending_review", "silence when nothing is aging")
|
||||||
|
|
||||||
|
writePending(t, dir, "ancient.md", 200*24*time.Hour, time.Now())
|
||||||
|
|
||||||
|
raw, err = srv.brainContext(context.Background(),
|
||||||
|
json.RawMessage(`{"project_root":"/home/mathias/dev/AGENTS/hyperguild"}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var withQueue map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &withQueue))
|
||||||
|
nudge, ok := withQueue["pending_review"].(map[string]any)
|
||||||
|
require.True(t, ok, "the nudge must reach the caller: %v", withQueue)
|
||||||
|
assert.Equal(t, float64(1), nudge["aging"])
|
||||||
|
assert.Equal(t, "ancient.md", nudge["oldest"])
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||||
// brain_pending, brain_promote, brain_index, brain_tunnel, brain_ingest,
|
// brain_pending, brain_promote, brain_tunnel, brain_ingest,
|
||||||
// brain_ingest_raw, brain_answer, brain_classify, brain_graph,
|
// brain_ingest_raw, brain_answer, brain_classify, brain_graph,
|
||||||
// brain_context, session_log, and capture (the #55 relay tool, registered
|
// brain_context, session_log, and capture (the #55 relay tool, registered
|
||||||
// only when WithCapture is set).
|
// only when WithCapture is set).
|
||||||
|
//
|
||||||
|
// brain_index dispatches but is deliberately absent from tools/list: it is
|
||||||
|
// auto-called after every write, so surfacing it as a caller verb is
|
||||||
|
// cognitive load with no decision behind it (ADR-0012 rule 4, #78). It stays
|
||||||
|
// callable for maintenance — de-surfacing hides the verb, not the operation.
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -44,6 +49,8 @@ type rpcError struct {
|
|||||||
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
brainDir string
|
brainDir string
|
||||||
|
govAudit capture.AuditSink
|
||||||
|
govNotify govNotifier
|
||||||
pipeline pipeline.Config
|
pipeline pipeline.Config
|
||||||
llm pipeline.CompleteFunc
|
llm pipeline.CompleteFunc
|
||||||
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
||||||
@@ -270,6 +277,15 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainPromote(ctx, args)
|
return s.brainPromote(ctx, args)
|
||||||
case "capture":
|
case "capture":
|
||||||
return s.brainCapture(ctx, args)
|
return s.brainCapture(ctx, args)
|
||||||
|
case "brain_read":
|
||||||
|
return s.brainRead(ctx, args)
|
||||||
|
case "brain_edit":
|
||||||
|
return s.brainEdit(ctx, args)
|
||||||
|
case "brain_edit_root":
|
||||||
|
// Dispatches but is intentionally absent from tools/list — see
|
||||||
|
// tools_notes.go for why a governance-file editor must not be
|
||||||
|
// discoverable by a model.
|
||||||
|
return s.brainEditRoot(ctx, args)
|
||||||
case "brain_index":
|
case "brain_index":
|
||||||
return s.brainIndex(ctx, args)
|
return s.brainIndex(ctx, args)
|
||||||
case "brain_tunnel":
|
case "brain_tunnel":
|
||||||
|
|||||||
@@ -59,11 +59,43 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||||
"brain_pending", "brain_promote",
|
"brain_pending", "brain_promote",
|
||||||
"brain_index", "brain_tunnel",
|
"brain_read", "brain_edit",
|
||||||
|
"brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||||
"session_log",
|
"session_log",
|
||||||
}, names)
|
}, names)
|
||||||
|
|
||||||
|
// brain_index is auto-called after every brain_write, so surfacing it as
|
||||||
|
// a caller verb is cognitive load with no decision behind it (ADR-0012
|
||||||
|
// rule 4, #78). The capability stays reachable — see the admin test below.
|
||||||
|
assert.NotContains(t, names, "brain_index")
|
||||||
|
|
||||||
|
// brain_edit_root is de-surfaced for a sharper reason than brain_index:
|
||||||
|
// brain content is ingested from the outside world and reaches a model's
|
||||||
|
// context, so a listed governance-file editor would be a durable
|
||||||
|
// prompt-injection path — one poisoned page rewriting the CLAUDE.md a
|
||||||
|
// .dispatch-allow agent later loads as instructions.
|
||||||
|
assert.NotContains(t, names, "brain_edit_root")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainIndexStaysCallableForAdminUseAfterDeSurfacing(t *testing.T) {
|
||||||
|
// De-surfacing hides the verb from the model-facing list; it must not
|
||||||
|
// remove the operation. A maintenance caller still needs to rebuild a
|
||||||
|
// Map of Content by hand.
|
||||||
|
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||||
|
"params": map[string]any{"name": "brain_index", "arguments": map[string]any{}},
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
assert.NotContains(t, resp, "error", "brain_index must still dispatch: %v", resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerNotificationGetsNoBody(t *testing.T) {
|
func TestServerNotificationGetsNoBody(t *testing.T) {
|
||||||
@@ -102,9 +134,11 @@ type stubTracker struct{}
|
|||||||
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,17 +58,13 @@ func captureToolDescriptor() map[string]any {
|
|||||||
},
|
},
|
||||||
"insights": map[string]any{"type": "array", "items": insightItem},
|
"insights": map[string]any{"type": "array", "items": insightItem},
|
||||||
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
||||||
"summary": map[string]any{"type": "object", "properties": map[string]any{
|
|
||||||
"title": str("summary title"), "body": str("summary body"),
|
|
||||||
"repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
||||||
}},
|
|
||||||
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(schema)
|
b, _ := json.Marshal(schema)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"name": "capture",
|
"name": "capture",
|
||||||
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets, optional summary → ai-sessions. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
||||||
"inputSchema": json.RawMessage(b),
|
"inputSchema": json.RawMessage(b),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ type capFakeTracker struct{}
|
|||||||
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) {
|
||||||
return capture.IssueRef{}, nil
|
return capture.IssueRef{}, nil
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,7 @@ func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []st
|
|||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
cfg, err := classification.Load(brainDir)
|
cfg, err := classification.Load(brainDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, nil, cfg, audit.NewSlogSink(nil))
|
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
return srv, brainDir
|
return srv, brainDir
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/textutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// brainContextArgs is the input shape of brain_context. project_root is
|
// brainContextArgs is the input shape of brain_context. project_root is
|
||||||
@@ -155,17 +157,21 @@ func (s *Server) brainContext(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
entries = entries[:limit]
|
entries = entries[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(map[string]any{"entries": entries})
|
out := map[string]any{"entries": entries}
|
||||||
|
// The queue nudge rides along with a call the session already makes, so
|
||||||
|
// noticing an aging note costs nobody a decision to go looking (#82).
|
||||||
|
// Advisory only, and absent entirely when there is nothing to say.
|
||||||
|
if n := pendingNudge(s.brainDir, time.Now(), pendingNudgeThreshold); n != nil {
|
||||||
|
out["pending_review"] = n
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateExcerpt clamps an already-stripped excerpt to maxLen characters
|
// truncateExcerpt clamps an already-stripped excerpt to maxLen characters
|
||||||
// without re-running the frontmatter parser. The ellipsis suffix matches
|
// without re-running the frontmatter parser. The ellipsis suffix matches
|
||||||
// the convention used in search.excerpt.
|
// the convention used in search.excerpt.
|
||||||
func truncateExcerpt(s string, maxLen int) string {
|
func truncateExcerpt(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
return textutil.Truncate(s, maxLen)
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:maxLen] + "…"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readExcerpt loads a doc relative to brainDir, strips its frontmatter,
|
// readExcerpt loads a doc relative to brainDir, strips its frontmatter,
|
||||||
@@ -185,10 +191,7 @@ func readExcerpt(brainDir, relPath string, maxLen int) string {
|
|||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
body = strings.TrimSpace(parts[2])
|
body = strings.TrimSpace(parts[2])
|
||||||
}
|
}
|
||||||
if len(body) > maxLen {
|
return textutil.Truncate(body, maxLen)
|
||||||
return body[:maxLen] + "…"
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// max1 returns the maximum of n and 1, used to guard against divide-by-zero
|
// max1 returns the maximum of n and 1, used to guard against divide-by-zero
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noteToolDescriptors are the model-facing halves of brain#4.
|
||||||
|
//
|
||||||
|
// brain_edit_root is deliberately absent: it dispatches but is not listed, the
|
||||||
|
// same de-surfacing ADR-0012 rule 4 applies to brain_index (#78). Hiding the
|
||||||
|
// verb matters here for a specific reason — brain content is ingested from the
|
||||||
|
// outside world and reaches a model's context, so a listed governance-file
|
||||||
|
// editor is a durable prompt-injection path: one poisoned page could rewrite
|
||||||
|
// the CLAUDE.md that a .dispatch-allow agent later obeys. A human who knows
|
||||||
|
// the tool exists can still call it; a model reading tools/list cannot
|
||||||
|
// discover it.
|
||||||
|
func noteToolDescriptors() []map[string]any {
|
||||||
|
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||||
|
schema := func(required []string, props map[string]any) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "brain_read",
|
||||||
|
"description": "Return a note's raw markdown verbatim — no synthesis, no excerpting. " +
|
||||||
|
"Use this to obtain the exact text before brain_edit; never build old_str from a " +
|
||||||
|
"brain_query excerpt, which is truncated and normalised and will not match.",
|
||||||
|
"inputSchema": schema([]string{"path"}, map[string]any{
|
||||||
|
"path": str("brain-relative path under knowledge/ or wiki/, e.g. wiki/homelab/facts/x.md"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "brain_edit",
|
||||||
|
"description": "Replace exactly one occurrence of old_str with new_str in a note, leaving " +
|
||||||
|
"every other byte identical. Fails loudly if old_str is absent or matches more than " +
|
||||||
|
"once — extend old_str until it is unique. Does NOT stamp updated_at or supersedes " +
|
||||||
|
"(brain_update does); it is a byte-level patch, not a metadata operation.",
|
||||||
|
"inputSchema": schema([]string{"path", "old_str", "new_str"}, map[string]any{
|
||||||
|
"path": str("brain-relative path under knowledge/ or wiki/"),
|
||||||
|
"old_str": str("exact text to replace; must occur exactly once"),
|
||||||
|
"new_str": str("replacement text; empty string deletes old_str"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hyperguild#97: governance-rewrite observability -----------------------
|
||||||
|
|
||||||
|
// govNotifier raises an out-of-band alert. Declared here rather than importing
|
||||||
|
// internal/audit so the MCP package keeps depending on capture alone.
|
||||||
|
type govNotifier interface {
|
||||||
|
Notify(ctx context.Context, msg string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGovernanceObserver attaches the audit sink and notifier that observe a
|
||||||
|
// successful brain_edit_root. Both are optional; either may be nil.
|
||||||
|
func (s *Server) WithGovernanceObserver(sink capture.AuditSink, n govNotifier) {
|
||||||
|
s.govAudit, s.govNotify = sink, n
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainReadArgs struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) brainRead(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
var a brainReadArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
content, err := notes.Read(s.brainDir, a.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{"path": a.Path, "content": string(content)})
|
||||||
|
}
|
||||||
|
|
||||||
|
type brainEditArgs struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
OldStr string `json:"old_str"`
|
||||||
|
NewStr string `json:"new_str"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// brainEdit is the model-facing editor: notes only.
|
||||||
|
//
|
||||||
|
// Deliberately NOT audited (#97). A note edit is already fully reconstructible
|
||||||
|
// from git history, which carries the same before/after bytes plus a diff.
|
||||||
|
// Governance files get the extra trail because they change what later agents
|
||||||
|
// obey — the asymmetry is about instruction-carrying content, not about how
|
||||||
|
// hard the change is to reconstruct. Auditing every note edit would also bury
|
||||||
|
// the rare governance record under routine traffic, which is how an alert
|
||||||
|
// stops being read.
|
||||||
|
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
_, out, err := s.editWithScope(args, notes.ScopeNotes)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
|
||||||
|
// the authenticated principal: only the static local-CLI token may call it, so
|
||||||
|
// a claude.ai-origin session — the one that may have just read an untrusted
|
||||||
|
// page — cannot rewrite the instructions later agents load.
|
||||||
|
func (s *Server) brainEditRoot(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||||
|
info, ok := ctx.Value(principalKey).(principalInfo)
|
||||||
|
if !ok || !info.viaStatic {
|
||||||
|
return nil, fmt.Errorf("brain_edit_root requires the local static principal; " +
|
||||||
|
"a governance file is not editable from a model-facing origin")
|
||||||
|
}
|
||||||
|
res, out, err := s.editWithScope(args, notes.ScopeRoot)
|
||||||
|
if err != nil {
|
||||||
|
// A refusal is not a rewrite. Recording or alerting on one would cry
|
||||||
|
// wolf, and the gate already logs its own denials.
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
// A durable instruction rewrite should be loud, not silent.
|
||||||
|
slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out))
|
||||||
|
s.observeGovernanceEdit(ctx, info.principal, res)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// observeGovernanceEdit records and alerts on a rewrite that already landed.
|
||||||
|
//
|
||||||
|
// Best-effort by construction: the bytes are written and brain-sync will commit
|
||||||
|
// them within the quarter hour, so failing the call here would report a
|
||||||
|
// non-rewrite to the caller while the rewrite sits on disk. A failure to
|
||||||
|
// observe is therefore logged at ERROR — the one case where the trail itself is
|
||||||
|
// what broke, and the only remaining signal.
|
||||||
|
func (s *Server) observeGovernanceEdit(ctx context.Context, principal string, res notes.Result) {
|
||||||
|
// Path and hashes only. The notification leaves the cluster for a phone
|
||||||
|
// via a third-party topic; a hash identifies the rewrite, the bytes would
|
||||||
|
// leak whatever was written into an instruction file.
|
||||||
|
msg := fmt.Sprintf("brain_edit_root applied\npath=%s\nprincipal=%s\nbefore=%s\nafter=%s",
|
||||||
|
res.Path, principal, res.PrevContentHash, res.ContentHash)
|
||||||
|
|
||||||
|
if s.govNotify != nil {
|
||||||
|
if err := s.govNotify.Notify(ctx, msg); err != nil {
|
||||||
|
slog.Error("brain_edit_root notify failed", "path", res.Path, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.govAudit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Internal, not Confidential: a governance file is not client data, and
|
||||||
|
// Confidential would make DegradingSink REFUSE to record while loki is
|
||||||
|
// down rather than buffer — losing exactly the record that matters most.
|
||||||
|
outcome, err := s.govAudit.Reserve(ctx, classification.Internal)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("brain_edit_root audit reserve failed", "path", res.Path, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := capture.AuditEntry{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Principal: principal,
|
||||||
|
Actor: "brain_edit_root",
|
||||||
|
Harness: "mcp",
|
||||||
|
EffectiveClassification: classification.Internal.String(),
|
||||||
|
Items: []string{res.Path},
|
||||||
|
SecurityEvents: []string{fmt.Sprintf(
|
||||||
|
"governance file rewritten: %s before=%s after=%s",
|
||||||
|
res.Path, res.PrevContentHash, res.ContentHash)},
|
||||||
|
}
|
||||||
|
if err := s.govAudit.Record(ctx, entry, outcome); err != nil {
|
||||||
|
slog.Error("brain_edit_root audit record failed", "path", res.Path, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.Result, json.RawMessage, error) {
|
||||||
|
var a brainEditArgs
|
||||||
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
|
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
|
||||||
|
}
|
||||||
|
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
||||||
|
if err != nil {
|
||||||
|
return notes.Result{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex only when the edit could have changed what the index reads.
|
||||||
|
// BuildWingIndex consumes title/created_at from the frontmatter block;
|
||||||
|
// BM25 walks the filesystem live and embeddings are mtime-driven, so an
|
||||||
|
// unconditional rebuild would buy nothing and dirty a second file —
|
||||||
|
// _index.md being precisely the class brain-sync already needs a bespoke
|
||||||
|
// conflict resolver for.
|
||||||
|
if wing := wingOf(res.Path); wing != "" && touchesFrontmatter(a.OldStr, a.NewStr) {
|
||||||
|
if ierr := brain.BuildWingIndex(s.brainDir, wing); ierr != nil {
|
||||||
|
// Best-effort, matching every other index hook here: the write
|
||||||
|
// already landed and will be committed, so failing the call would
|
||||||
|
// make the caller retry an edit whose old_str no longer exists.
|
||||||
|
res.ReindexError = ierr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, merr := json.Marshal(res)
|
||||||
|
return res, out, merr
|
||||||
|
}
|
||||||
|
|
||||||
|
// wingOf returns the wing for a wiki path, or "" for knowledge/ and root.
|
||||||
|
func wingOf(rel string) string {
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
if len(parts) < 3 || parts[0] != "wiki" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// touchesFrontmatter reports whether either side of the edit mentions a field
|
||||||
|
// the wing index reads.
|
||||||
|
func touchesFrontmatter(oldStr, newStr string) bool {
|
||||||
|
for _, s := range []string{oldStr, newStr} {
|
||||||
|
if strings.Contains(s, "title:") || strings.Contains(s, "created_at:") || strings.Contains(s, "---") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// ingestion/internal/mcp/tools_notes_test.go
|
||||||
|
package mcp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- brain#4: brain_read / brain_edit through the JSON-RPC surface --------
|
||||||
|
|
||||||
|
func newBrainRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/homelab/facts/n.md"),
|
||||||
|
[]byte("---\ntitle: 'n'\n---\n\nalpha\nbeta\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("# rules\n"), 0o644))
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(t *testing.T, srv *mcp.Server, name string, args map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||||
|
"params": map[string]any{"name": name, "arguments": args},
|
||||||
|
}))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
|
||||||
|
// The Go functions are tested in internal/notes; this pins the transport,
|
||||||
|
// because a tool that works in-process and is unreachable over JSON-RPC
|
||||||
|
// is indistinguishable from a working one in a unit test.
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := call(t, srv, "brain_read", map[string]any{"path": "wiki/homelab/facts/n.md"})
|
||||||
|
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||||
|
|
||||||
|
resp = call(t, srv, "brain_edit", map[string]any{
|
||||||
|
"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA",
|
||||||
|
})
|
||||||
|
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/n.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "---\ntitle: 'n'\n---\n\nalpha\nBETA\n", string(got),
|
||||||
|
"only the matched bytes may change")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := call(t, srv, "brain_edit", map[string]any{
|
||||||
|
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||||
|
})
|
||||||
|
require.Contains(t, resp, "error", "brain_edit must not reach CLAUDE.md")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# rules\n", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainEditRoot_RefusedWithoutTheLocalStaticPrincipal(t *testing.T) {
|
||||||
|
// No principal in context is the model-facing case (and the default in
|
||||||
|
// this test server): the privileged door must stay shut.
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := call(t, srv, "brain_edit_root", map[string]any{
|
||||||
|
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||||
|
})
|
||||||
|
require.Contains(t, resp, "error")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# rules\n", string(got), "governance file must be untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainGet_CannotReachSessionTranscripts(t *testing.T) {
|
||||||
|
// Pre-existing hole, not introduced here: api.ReadNote resolves any
|
||||||
|
// brain-relative path and parseFrontmatter returns the WHOLE file as body
|
||||||
|
// when there is no frontmatter fence. archive/ holds verbatim Claude
|
||||||
|
// session transcripts (up to 500 KB) that BM25 never indexes, so they are
|
||||||
|
// invisible to search and trivially readable by path — and CLAUDE.md
|
||||||
|
// documents the layout.
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "archive/claude-sessions/koala"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
filepath.Join(dir, "archive/claude-sessions/koala/session-1.md"),
|
||||||
|
[]byte("verbatim transcript with tool output\n"), 0o644))
|
||||||
|
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
resp := call(t, srv, "brain_get", map[string]any{"path": "archive/claude-sessions/koala/session-1.md"})
|
||||||
|
|
||||||
|
require.Contains(t, resp, "error", "brain_get must not read outside knowledge/ and wiki/")
|
||||||
|
assert.NotContains(t, string(mustJSON(t, resp)), "verbatim transcript")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainGet_StillReadsNotes(t *testing.T) {
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := call(t, srv, "brain_get", map[string]any{"path": "wiki/homelab/facts/n.md"})
|
||||||
|
assert.NotContains(t, resp, "error", "%v", resp["error"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(t *testing.T, v any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- hyperguild#97: a governance rewrite must be observed, not just gated ----
|
||||||
|
|
||||||
|
// govSink captures audit records. Governance edits are rare, so the whole
|
||||||
|
// history is kept and asserted on rather than sampled.
|
||||||
|
type govSink struct {
|
||||||
|
entries []capture.AuditEntry
|
||||||
|
outcomes []capture.AuditOutcome
|
||||||
|
recErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *govSink) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
|
||||||
|
return capture.AuditCentral, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *govSink) Record(_ context.Context, e capture.AuditEntry, o capture.AuditOutcome) error {
|
||||||
|
s.entries = append(s.entries, e)
|
||||||
|
s.outcomes = append(s.outcomes, o)
|
||||||
|
return s.recErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type govNotifier struct {
|
||||||
|
msgs []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *govNotifier) Notify(_ context.Context, msg string) error {
|
||||||
|
n.msgs = append(n.msgs, msg)
|
||||||
|
return n.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// govServer wires capture (which is what re-derives the static principal) plus
|
||||||
|
// the #97 observer, and returns the brain dir so the test can read the file.
|
||||||
|
func govServer(t *testing.T) (*mcp.Server, string, *govSink, *govNotifier) {
|
||||||
|
t.Helper()
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
cfg, err := classification.Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||||
|
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
srv.WithCapture(svc, nil, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
sink, notifier := &govSink{}, &govNotifier{}
|
||||||
|
srv.WithGovernanceObserver(sink, notifier)
|
||||||
|
return srv, dir, sink, notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// authCall is call() with an Authorization header, so the static principal is
|
||||||
|
// present and brain_edit_root's gate opens.
|
||||||
|
func authCall(t *testing.T, srv *mcp.Server, authz, name string, args map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||||
|
"params": map[string]any{"name": name, "arguments": args},
|
||||||
|
}))
|
||||||
|
if authz != "" {
|
||||||
|
req.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
var resp map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256hex(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainEditRoot_RecordsAnAuditEntryNamingPrincipalPathAndBothHashes(t *testing.T) {
|
||||||
|
// The gate proves a rewrite was ALLOWED. Only a record proves one HAPPENED
|
||||||
|
// — and brain-sync will commit it within 15 minutes authored `brain-sync`,
|
||||||
|
// so git history cannot tell it apart from routine note traffic.
|
||||||
|
srv, dir, sink, _ := govServer(t)
|
||||||
|
|
||||||
|
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
||||||
|
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||||
|
})
|
||||||
|
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||||
|
|
||||||
|
require.Len(t, sink.entries, 1, "exactly one audit record per governance rewrite")
|
||||||
|
e := sink.entries[0]
|
||||||
|
assert.Equal(t, "local-cli", e.Principal)
|
||||||
|
assert.Contains(t, e.Items, "CLAUDE.md", "the record must name what was rewritten")
|
||||||
|
|
||||||
|
joined := strings.Join(e.SecurityEvents, " ")
|
||||||
|
assert.Contains(t, joined, sha256hex("# rules\n"), "before-hash must be recorded")
|
||||||
|
assert.Contains(t, joined, sha256hex("# rewritten\n"), "after-hash must be recorded")
|
||||||
|
assert.NotZero(t, e.Timestamp)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# rewritten\n", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainEditRoot_NotifiesWithPathAndHashesButNeverContent(t *testing.T) {
|
||||||
|
srv, _, _, notifier := govServer(t)
|
||||||
|
|
||||||
|
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
||||||
|
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||||
|
})
|
||||||
|
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||||
|
|
||||||
|
require.Len(t, notifier.msgs, 1)
|
||||||
|
msg := notifier.msgs[0]
|
||||||
|
assert.Contains(t, msg, "CLAUDE.md")
|
||||||
|
assert.Contains(t, msg, "local-cli")
|
||||||
|
assert.Contains(t, msg, sha256hex("# rules\n"))
|
||||||
|
assert.Contains(t, msg, sha256hex("# rewritten\n"))
|
||||||
|
|
||||||
|
// The notification travels to a phone through a third-party topic. A hash
|
||||||
|
// identifies a rewrite; the bytes would leak whatever was written.
|
||||||
|
assert.NotContains(t, msg, "# rewritten", "no file content in the notification body")
|
||||||
|
assert.NotContains(t, msg, "# rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainEditRoot_RefusedProducesNoRecordNoNotificationNoWrite(t *testing.T) {
|
||||||
|
srv, dir, sink, notifier := govServer(t)
|
||||||
|
|
||||||
|
// No Authorization header → no static principal → the gate stays shut.
|
||||||
|
resp := authCall(t, srv, "", "brain_edit_root", map[string]any{
|
||||||
|
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
|
||||||
|
})
|
||||||
|
require.Contains(t, resp, "error")
|
||||||
|
|
||||||
|
assert.Empty(t, sink.entries, "a refusal is not a rewrite — recording one would cry wolf")
|
||||||
|
assert.Empty(t, notifier.msgs)
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "# rules\n", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainEditRoot_FailedEditProducesNoRecordNoNotification(t *testing.T) {
|
||||||
|
// Authorised but no match: the door opened and nothing went through it.
|
||||||
|
srv, _, sink, notifier := govServer(t)
|
||||||
|
|
||||||
|
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
|
||||||
|
"path": "CLAUDE.md", "old_str": "absent-from-the-file", "new_str": "x",
|
||||||
|
})
|
||||||
|
require.Contains(t, resp, "error")
|
||||||
|
|
||||||
|
assert.Empty(t, sink.entries)
|
||||||
|
assert.Empty(t, notifier.msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrainEdit_OnOrdinaryNotesIsNotAudited(t *testing.T) {
|
||||||
|
// The decided asymmetry (#97): note edits are covered by git history, which
|
||||||
|
// carries the same before/after bytes with a diff. Governance files get the
|
||||||
|
// extra trail because they change what later agents obey, not because they
|
||||||
|
// are harder to reconstruct.
|
||||||
|
srv, _, sink, notifier := govServer(t)
|
||||||
|
|
||||||
|
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit", map[string]any{
|
||||||
|
"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA",
|
||||||
|
})
|
||||||
|
require.NotContains(t, resp, "error", "%v", resp["error"])
|
||||||
|
|
||||||
|
assert.Empty(t, sink.entries, "note edits must not fill the governance audit trail")
|
||||||
|
assert.Empty(t, notifier.msgs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package notes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxNoteBytes caps what Read will hand back. Generous for knowledge/ and
|
||||||
|
// wiki/ (the largest note today is far under it) and small enough that a
|
||||||
|
// pathological file cannot flood a caller's context.
|
||||||
|
const maxNoteBytes = 256 << 10
|
||||||
|
|
||||||
|
// Errors callers distinguish.
|
||||||
|
var (
|
||||||
|
ErrNoMatch = errors.New("notes: old_str not found")
|
||||||
|
ErrAmbiguous = errors.New("notes: old_str matches more than once")
|
||||||
|
ErrNotUTF8 = errors.New("notes: content is not valid UTF-8")
|
||||||
|
ErrGitBusy = errors.New("notes: a git operation is in progress")
|
||||||
|
ErrTooLarge = errors.New("notes: file exceeds the readable size limit")
|
||||||
|
ErrDegenerate = errors.New("notes: edit is degenerate")
|
||||||
|
ErrInConflict = errors.New("notes: file contains unresolved conflict markers")
|
||||||
|
)
|
||||||
|
|
||||||
|
// pathLocks serialises edits per file within this process. The flock below
|
||||||
|
// covers the cross-process case (two pods share the hostPath during a rolling
|
||||||
|
// update, and brain-fix/brain-enrich write the same tree).
|
||||||
|
var pathLocks sync.Map // string -> *sync.Mutex
|
||||||
|
|
||||||
|
// Result is what a successful edit reports.
|
||||||
|
type Result struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
// PrevContentHash is the sha256 of the file as it was before this edit.
|
||||||
|
// Paired with ContentHash it identifies a rewrite in an audit trail or an
|
||||||
|
// alert without carrying any of the bytes (hyperguild#97).
|
||||||
|
PrevContentHash string `json:"prev_content_hash"`
|
||||||
|
ContentHash string `json:"content_hash"`
|
||||||
|
BytesBefore int `json:"bytes_before"`
|
||||||
|
BytesAfter int `json:"bytes_after"`
|
||||||
|
ReindexError string `json:"reindex_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read returns a note's bytes verbatim.
|
||||||
|
//
|
||||||
|
// No frontmatter parsing, no excerpting, no normalisation: the entire purpose
|
||||||
|
// is to give a caller an exact basis for an edit. An excerpt from brain_query
|
||||||
|
// would be truncated and normalised, and every old_str built from one would
|
||||||
|
// miss.
|
||||||
|
func Read(brainDir, path string) ([]byte, error) {
|
||||||
|
t, err := Resolve(brainDir, path, ScopeNotes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return readContained(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadScoped is Read for a caller already holding the privileged scope.
|
||||||
|
func ReadScoped(brainDir, path string, scope Scope) ([]byte, error) {
|
||||||
|
t, err := Resolve(brainDir, path, scope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return readContained(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readContained(t Target) ([]byte, error) {
|
||||||
|
fi, err := os.Stat(t.Abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notes: stat %s: %w", t.Rel, err)
|
||||||
|
}
|
||||||
|
if fi.Size() > maxNoteBytes {
|
||||||
|
return nil, fmt.Errorf("%w: %s is %d bytes (limit %d)", ErrTooLarge, t.Rel, fi.Size(), maxNoteBytes)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(t.Abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notes: read %s: %w", t.Rel, err)
|
||||||
|
}
|
||||||
|
// The MCP response marshals this into a JSON string, and encoding/json
|
||||||
|
// substitutes U+FFFD for invalid UTF-8 — silently returning different
|
||||||
|
// bytes than the file holds, which defeats "verbatim" entirely.
|
||||||
|
if !utf8.Valid(b) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrNotUTF8, t.Rel)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit replaces exactly one occurrence of oldStr with newStr, in place.
|
||||||
|
//
|
||||||
|
// Fails loudly and writes nothing on: zero matches, more than one match
|
||||||
|
// (counted with overlap), degenerate arguments, invalid UTF-8, a git operation
|
||||||
|
// in flight, or unresolved conflict markers in the target.
|
||||||
|
func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
|
||||||
|
t, err := Resolve(brainDir, path, scope)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if oldStr == "" {
|
||||||
|
return Result{}, fmt.Errorf("%w: old_str is empty", ErrDegenerate)
|
||||||
|
}
|
||||||
|
if oldStr == newStr {
|
||||||
|
return Result{}, fmt.Errorf("%w: old_str equals new_str", ErrDegenerate)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(newStr) {
|
||||||
|
return Result{}, fmt.Errorf("%w: new_str", ErrNotUTF8)
|
||||||
|
}
|
||||||
|
// brain#2 guard: refuse to write to a tree the sync does not commit from.
|
||||||
|
if err := assertSyncSource(brainDir); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if err := assertGitIdle(brainDir); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mu := lockFor(t.Abs)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
unlock, err := flockFile(t.Abs)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// Read INSIDE the lock: the match count must be evaluated against the
|
||||||
|
// bytes we are about to overwrite, or a concurrent edit is silently lost
|
||||||
|
// while both callers are told they succeeded.
|
||||||
|
before, err := readContained(t)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if hasConflictMarkers(before) {
|
||||||
|
return Result{}, fmt.Errorf("%w: %s", ErrInConflict, t.Rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch n := countOverlapping(string(before), oldStr); {
|
||||||
|
case n == 0:
|
||||||
|
return Result{}, fmt.Errorf("%w in %s (0 matches)%s", ErrNoMatch, t.Rel, crlfHint(string(before), oldStr))
|
||||||
|
case n > 1:
|
||||||
|
return Result{}, fmt.Errorf("%w in %s (%d matches) — extend old_str until it is unique",
|
||||||
|
ErrAmbiguous, t.Rel, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := strings.Replace(string(before), oldStr, newStr, 1)
|
||||||
|
if err := writeAtomic(t.Abs, []byte(after)); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(after))
|
||||||
|
prev := sha256.Sum256(before)
|
||||||
|
return Result{
|
||||||
|
Path: t.Rel,
|
||||||
|
PrevContentHash: hex.EncodeToString(prev[:]),
|
||||||
|
ContentHash: hex.EncodeToString(sum[:]),
|
||||||
|
BytesBefore: len(before),
|
||||||
|
BytesAfter: len(after),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countOverlapping counts occurrences allowing overlap. bytes.Count does not:
|
||||||
|
// "aa" occurs once in "aaa" by its reckoning, so a Count-based uniqueness
|
||||||
|
// check accepts an ambiguous edit as unique.
|
||||||
|
func countOverlapping(s, sub string) int {
|
||||||
|
var n, from int
|
||||||
|
for {
|
||||||
|
i := strings.Index(s[from:], sub)
|
||||||
|
if i < 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
from += i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crlfHint reports whether the caller's old_str would have matched if line
|
||||||
|
// endings agreed. It never normalises — that would break byte-identity — but
|
||||||
|
// an opaque "no match" is unfixable from the caller's side, and a stripped \r
|
||||||
|
// is the likeliest cause.
|
||||||
|
func crlfHint(content, oldStr string) string {
|
||||||
|
if strings.Contains(content, "\r\n") && !strings.Contains(oldStr, "\r\n") {
|
||||||
|
if strings.Contains(strings.ReplaceAll(content, "\r\n", "\n"), oldStr) {
|
||||||
|
return " — the file uses CRLF line endings and old_str does not; include the \\r"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasConflictMarkers(b []byte) bool {
|
||||||
|
s := string(b)
|
||||||
|
return strings.Contains(s, "\n<<<<<<< ") || strings.HasPrefix(s, "<<<<<<< ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertGitIdle refuses while brain-sync (or brain-fix, or a human) has a git
|
||||||
|
// operation in flight. An edit landing between `git merge` and its abort is
|
||||||
|
// either hard-reset away or committed as a wrongly resolved conflict — and the
|
||||||
|
// caller was told it succeeded.
|
||||||
|
func assertGitIdle(brainDir string) error {
|
||||||
|
for _, marker := range []string{"MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "index.lock"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(brainDir, ".git", marker)); err == nil {
|
||||||
|
return fmt.Errorf("%w (.git/%s present) — retry after the sync settles", ErrGitBusy, marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockFor(abs string) *sync.Mutex {
|
||||||
|
v, _ := pathLocks.LoadOrStore(abs, &sync.Mutex{})
|
||||||
|
return v.(*sync.Mutex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flockFile takes an advisory lock covering other processes sharing the
|
||||||
|
// hostPath — during a RollingUpdate two ingestion pods do.
|
||||||
|
func flockFile(abs string) (func(), error) {
|
||||||
|
f, err := os.OpenFile(abs+".lock", os.O_CREATE|os.O_RDWR, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notes: lock %s: %w", filepath.Base(abs), err)
|
||||||
|
}
|
||||||
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, fmt.Errorf("notes: flock %s: %w", filepath.Base(abs), err)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(abs + ".lock")
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAtomic replaces the file via a same-directory temp + rename, then
|
||||||
|
// fsyncs the directory so the rename survives a node crash.
|
||||||
|
//
|
||||||
|
// Same directory because a rename across the hostPath boundary fails with
|
||||||
|
// EXDEV; the ".tmp" suffix because the tree's .gitignore already covers it, so
|
||||||
|
// a leaked temp is not committed by `git add -A`.
|
||||||
|
func writeAtomic(abs string, content []byte) error {
|
||||||
|
dir := filepath.Dir(abs)
|
||||||
|
tmp, err := os.CreateTemp(dir, filepath.Base(abs)+".*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notes: temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
|
||||||
|
|
||||||
|
if _, err := tmp.Write(content); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("notes: write temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return fmt.Errorf("notes: sync temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("notes: close temp: %w", err)
|
||||||
|
}
|
||||||
|
// CreateTemp makes 0600; every other writer of this tree uses 0644 and
|
||||||
|
// other uids read it.
|
||||||
|
if err := os.Chmod(tmpName, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("notes: chmod temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, abs); err != nil {
|
||||||
|
return fmt.Errorf("notes: rename: %w", err)
|
||||||
|
}
|
||||||
|
return fsyncDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fsyncDir(dir string) error {
|
||||||
|
d, err := os.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notes: open dir: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = d.Close() }()
|
||||||
|
if err := d.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("notes: fsync dir: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package notes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seed(t *testing.T, dir, rel, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
abs := filepath.Join(dir, rel)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return abs
|
||||||
|
}
|
||||||
|
|
||||||
|
func read(t *testing.T, abs string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_ReturnsVerbatimBytes(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
const body = "---\ntitle: 'x'\n---\n\nbody with double spaces\r\nand a CRLF line\nno trailing newline"
|
||||||
|
seed(t, dir, "knowledge/verbatim.md", body)
|
||||||
|
|
||||||
|
got, err := Read(dir, "knowledge/verbatim.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != body {
|
||||||
|
t.Errorf("Read returned %q, want the file byte-for-byte", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRead_RefusesNonUTF8RatherThanCorruptingIt(t *testing.T) {
|
||||||
|
// The response is marshalled into a JSON string, and encoding/json
|
||||||
|
// replaces invalid UTF-8 with U+FFFD. Returning it would mean every
|
||||||
|
// old_str the caller derives from that region can never match, and
|
||||||
|
// pasting it back would write the corruption to disk permanently.
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/bad.md", "ok \xff\xfe broken\n")
|
||||||
|
|
||||||
|
if _, err := Read(dir, "knowledge/bad.md"); err == nil {
|
||||||
|
t.Fatal("Read accepted invalid UTF-8; it must refuse rather than silently substitute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_ReplacesExactlyOnceAndLeavesEveryOtherByteAlone(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
const body = "---\ntitle: 'x'\n---\n\nalpha\nbeta\ngamma\n"
|
||||||
|
abs := seed(t, dir, "knowledge/n.md", body)
|
||||||
|
|
||||||
|
res, err := Edit(dir, "knowledge/n.md", "beta", "BETA", ScopeNotes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Edit: %v", err)
|
||||||
|
}
|
||||||
|
want := strings.Replace(body, "beta", "BETA", 1)
|
||||||
|
if got := read(t, abs); got != want {
|
||||||
|
t.Errorf("file = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if res.ContentHash == "" {
|
||||||
|
t.Error("result should carry a content hash for read-after-write confirmation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_RefusesAmbiguousAndAbsentMatchesWithoutWriting(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
const body = "alpha\nalpha\n"
|
||||||
|
abs := seed(t, dir, "knowledge/n.md", body)
|
||||||
|
|
||||||
|
cases := []struct{ name, old, want string }{
|
||||||
|
{"two matches", "alpha", "2"},
|
||||||
|
{"no match", "delta", "0"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := Edit(dir, "knowledge/n.md", tc.old, "x", ScopeNotes)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want a loud refusal")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Errorf("error must state the match count, got %v", err)
|
||||||
|
}
|
||||||
|
if got := read(t, abs); got != body {
|
||||||
|
t.Errorf("file changed on a refused edit: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_CountsOverlappingMatches(t *testing.T) {
|
||||||
|
// bytes.Count counts NON-overlapping occurrences: "aa" in "aaa" is 1, so
|
||||||
|
// a Count-based uniqueness check calls an ambiguous edit unique.
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/n.md", "aaa\n")
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "aa", "b", ScopeNotes); err == nil {
|
||||||
|
t.Fatal("overlapping matches must be reported as ambiguous")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_RejectsDegenerateArguments(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "", "x", ScopeNotes); err == nil {
|
||||||
|
t.Error("empty old_str must be rejected")
|
||||||
|
}
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "alpha", ScopeNotes); err == nil {
|
||||||
|
t.Error("a no-op edit must be rejected rather than burning a sync cycle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_DeletionIsLegal(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
abs := seed(t, dir, "knowledge/n.md", "keep\ndrop\n")
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "drop\n", "", ScopeNotes); err != nil {
|
||||||
|
t.Fatalf("Edit: %v", err)
|
||||||
|
}
|
||||||
|
if got := read(t, abs); got != "keep\n" {
|
||||||
|
t.Errorf("file = %q, want %q", got, "keep\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_ReportsWhenOnlyLineEndingsDiffer(t *testing.T) {
|
||||||
|
// The likeliest real failure: the caller's old_str lost its \r. Never
|
||||||
|
// normalise silently — that breaks byte-identity — but say so, because an
|
||||||
|
// opaque "no match" is unfixable from the caller's side.
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/n.md", "alpha\r\nbeta\r\n")
|
||||||
|
|
||||||
|
_, err := Edit(dir, "knowledge/n.md", "alpha\nbeta", "x", ScopeNotes)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want a refusal")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "line ending") {
|
||||||
|
t.Errorf("error should point at CRLF, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_RefusesWhileGitIsMidOperation(t *testing.T) {
|
||||||
|
// brain-sync runs `git merge -X ours origin/main`, and aborts on conflict
|
||||||
|
// with a hard reset. An edit landing inside that window is either erased
|
||||||
|
// or committed as a wrongly-resolved conflict, and the caller was told it
|
||||||
|
// succeeded.
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
||||||
|
|
||||||
|
for _, marker := range []string{"MERGE_HEAD", "index.lock", "REBASE_HEAD"} {
|
||||||
|
t.Run(marker, func(t *testing.T) {
|
||||||
|
p := filepath.Join(dir, ".git", marker)
|
||||||
|
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Remove(p) }()
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err == nil {
|
||||||
|
t.Errorf("edit accepted while %s exists", marker)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_RefusesAFileHoldingConflictMarkers(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/n.md", "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> origin/main\n")
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "ours", "x", ScopeNotes); err == nil {
|
||||||
|
t.Fatal("editing one side of an unresolved conflict must be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_ConcurrentEditsBothApply(t *testing.T) {
|
||||||
|
// Read-modify-write with no lock loses one update AND reports success for
|
||||||
|
// both. old_str uniqueness does not protect against it: uniqueness is
|
||||||
|
// evaluated against the stale read.
|
||||||
|
dir := newBrain(t)
|
||||||
|
abs := seed(t, dir, "knowledge/n.md", "alpha\nbeta\n")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make([]error, 2)
|
||||||
|
for i, pair := range [][2]string{{"alpha", "ALPHA"}, {"beta", "BETA"}} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, old, new string) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, errs[i] = Edit(dir, "knowledge/n.md", old, new, ScopeNotes)
|
||||||
|
}(i, pair[0], pair[1])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("edit %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := read(t, abs); got != "ALPHA\nBETA\n" {
|
||||||
|
t.Errorf("file = %q, want both edits applied", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_LeavesNoTemporaryFileBehind(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, "knowledge"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() != "note.md" && e.Name() != "n.md" {
|
||||||
|
t.Errorf("stray file left in the tree: %q (git add -A would commit it)", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_PreservesReadableMode(t *testing.T) {
|
||||||
|
// Other uids write this tree (brain-fix, brain-enrich). os.CreateTemp
|
||||||
|
// yields 0600 and rename preserves it, which would break them silently.
|
||||||
|
dir := newBrain(t)
|
||||||
|
abs := seed(t, dir, "knowledge/n.md", "alpha\n")
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fi.Mode().Perm() != 0o644 {
|
||||||
|
t.Errorf("mode = %v, want 0644", fi.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_RefusesNonUTF8Replacement(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
||||||
|
|
||||||
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "bad \xff", ScopeNotes); err == nil {
|
||||||
|
t.Fatal("new_str with invalid UTF-8 must be refused, not written")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Package notes provides containment-checked read and surgical edit of brain
|
||||||
|
// notes on the live tree that brain-sync commits from.
|
||||||
|
//
|
||||||
|
// Everything here is defensive for one reason: brain content is ingested from
|
||||||
|
// the outside world, that content reaches a model's context via brain_query /
|
||||||
|
// brain_context, and these primitives are callable by that model. A path bug
|
||||||
|
// is therefore not a path bug — it is an outside author choosing a file.
|
||||||
|
package notes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope is which part of the tree a caller is allowed to touch.
|
||||||
|
type Scope int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ScopeNotes is the model-facing scope: markdown under knowledge/ and
|
||||||
|
// wiki/ only.
|
||||||
|
ScopeNotes Scope = iota
|
||||||
|
// ScopeRoot admits the named governance files and NOTHING else. It exists so
|
||||||
|
// there is an MCP path to CLAUDE.md at all — the absence of one is what
|
||||||
|
// pushed a session to edit via the gitea REST API on 2026-06-16, which
|
||||||
|
// never reached the live tree and wedged brain-sync for hours. It is NOT
|
||||||
|
// reachable from the model-facing toolset; see tools_notes.go.
|
||||||
|
ScopeRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
// rootFiles is an exact-name allowlist, deliberately not a "root *.md" glob.
|
||||||
|
//
|
||||||
|
// Two reasons. log.md is gitignored, so an edit to it applies to the live tree
|
||||||
|
// and never reaches gitea — the exact shape of brain#2, admitted by a glob. And
|
||||||
|
// a glob auto-admits whatever governance file appears next (AGENTS.md, a
|
||||||
|
// bootstrap doc) with nobody reviewing that decision.
|
||||||
|
var rootFiles = map[string]bool{"CLAUDE.md": true}
|
||||||
|
|
||||||
|
// noteDirs are the two trees that hold notes.
|
||||||
|
var noteDirs = []string{"knowledge/", "wiki/"}
|
||||||
|
|
||||||
|
// deniedPrefixes are refused in every scope, independent of the allowlist.
|
||||||
|
//
|
||||||
|
// A static backstop rather than a consequence of the allowlist being right:
|
||||||
|
// .git/hooks/post-commit is executed by brain-sync inside a pod holding the
|
||||||
|
// Gitea push credentials, and archive/ + sessions/ hold verbatim session
|
||||||
|
// transcripts that BM25 never indexes and so nobody thinks of as reachable.
|
||||||
|
var deniedPrefixes = []string{".git/", "archive/", "sessions/", "raw/", "training-data/", ".obsidian/"}
|
||||||
|
|
||||||
|
// Errors are sentinels so callers can distinguish a refusal from an I/O fault.
|
||||||
|
var (
|
||||||
|
ErrOutsideBrain = errors.New("notes: path escapes the brain dir")
|
||||||
|
ErrNotAllowed = errors.New("notes: path is not editable through this tool")
|
||||||
|
ErrNotSyncSource = errors.New("notes: brain dir is not the sync source")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target is a resolved, contained path.
|
||||||
|
type Target struct {
|
||||||
|
Rel string // brain-relative, cleaned
|
||||||
|
Abs string // absolute, symlink-checked
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve canonicalises path, then checks it against the scope's allowlist,
|
||||||
|
// then re-checks containment after symlink evaluation.
|
||||||
|
//
|
||||||
|
// Order matters and is the point: a prefix test on the raw argument accepts
|
||||||
|
// "knowledge/../.git/hooks/post-commit", because Clean only collapses it
|
||||||
|
// afterwards.
|
||||||
|
func Resolve(brainDir, path string, scope Scope) (Target, error) {
|
||||||
|
// Note: the sync-source assertion lives on the WRITE path (Edit), not
|
||||||
|
// here. brain#2 was an edit landing where the sync could not commit it; a
|
||||||
|
// read from such a tree is merely a read.
|
||||||
|
|
||||||
|
// 1. Canonicalise. Rooting at "/" makes Clean collapse every ".." that
|
||||||
|
// would otherwise escape, and ToSlash normalises separators first.
|
||||||
|
clean := strings.TrimPrefix(filepath.Clean("/"+filepath.ToSlash(path)), "/")
|
||||||
|
if clean == "" || clean == "." {
|
||||||
|
return Target{}, fmt.Errorf("%w: empty path", ErrNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Static denials, before any allowlist reasoning.
|
||||||
|
for _, d := range deniedPrefixes {
|
||||||
|
if strings.HasPrefix(clean+"/", d) {
|
||||||
|
return Target{}, fmt.Errorf("%w: %q is not note content", ErrNotAllowed, clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(filepath.Base(clean), ".") {
|
||||||
|
return Target{}, fmt.Errorf("%w: dotfiles are not editable", ErrNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Scope allowlist.
|
||||||
|
if err := allow(clean, scope); err != nil {
|
||||||
|
return Target{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Containment, lexically.
|
||||||
|
abs := filepath.Join(brainDir, filepath.FromSlash(clean))
|
||||||
|
if !within(brainDir, abs) {
|
||||||
|
return Target{}, fmt.Errorf("%w: %q", ErrOutsideBrain, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Containment again, after resolving symlinks. The tree is Syncthing
|
||||||
|
// managed, so a link can appear without anyone adding one on purpose;
|
||||||
|
// a symlinked note or directory would otherwise read and write outside
|
||||||
|
// the brain entirely.
|
||||||
|
if err := assertNoSymlinkEscape(brainDir, abs); err != nil {
|
||||||
|
return Target{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Target{Rel: clean, Abs: abs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow enforces the scope's allowlist. The two scopes are DISJOINT, not
|
||||||
|
// nested: the privileged root scope reaches governance files and nothing else,
|
||||||
|
// so the tool that carries the higher privilege carries the smaller reach.
|
||||||
|
func allow(clean string, scope Scope) error {
|
||||||
|
if scope == ScopeRoot {
|
||||||
|
if !rootFiles[clean] {
|
||||||
|
return fmt.Errorf("%w: %q is not a governance file", ErrNotAllowed, clean)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isNotePath(clean) {
|
||||||
|
return fmt.Errorf("%w: %q is outside knowledge/ and wiki/", ErrNotAllowed, clean)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNotePath(clean string) bool {
|
||||||
|
var inDir bool
|
||||||
|
for _, d := range noteDirs {
|
||||||
|
if strings.HasPrefix(clean, d) {
|
||||||
|
inDir = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !inDir || filepath.Ext(clean) != ".md" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// _index.md is generated per wing, and brain-sync's conflict resolver
|
||||||
|
// deliberately resolves it in origin's favour — an edit here is reverted
|
||||||
|
// by design, so accepting one would be a lie.
|
||||||
|
return filepath.Base(clean) != "_index.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
func within(root, abs string) bool {
|
||||||
|
rel, err := filepath.Rel(root, abs)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoSymlinkEscape resolves the deepest existing ancestor of abs and
|
||||||
|
// requires it to still be inside brainDir. The file itself need not exist
|
||||||
|
// (a create is legal); its directory chain must be real and contained.
|
||||||
|
func assertNoSymlinkEscape(brainDir, abs string) error {
|
||||||
|
if fi, err := os.Lstat(abs); err == nil && fi.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("%w: %q is a symlink", ErrNotAllowed, abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
realRoot, err := filepath.EvalSymlinks(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notes: resolve brain dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(abs)
|
||||||
|
for {
|
||||||
|
resolved, err := filepath.EvalSymlinks(dir)
|
||||||
|
if err == nil {
|
||||||
|
if !within(realRoot, resolved) {
|
||||||
|
return fmt.Errorf("%w: %q resolves outside the brain dir", ErrOutsideBrain, dir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("notes: resolve %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return fmt.Errorf("%w: no existing ancestor for %q", ErrOutsideBrain, abs)
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertSyncSource refuses a brain dir that is not a git working tree.
|
||||||
|
//
|
||||||
|
// brain#2 was an edit landing on a directory the sync does not commit from.
|
||||||
|
// That cannot be prevented by review — a wrong INGEST_BRAIN_DIR in a future
|
||||||
|
// manifest reintroduces it while every unit test still passes — so it is
|
||||||
|
// asserted at runtime, the same check brain-sync itself makes before running.
|
||||||
|
func assertSyncSource(brainDir string) error {
|
||||||
|
if fi, err := os.Stat(filepath.Join(brainDir, ".git")); err != nil || !fi.IsDir() {
|
||||||
|
return fmt.Errorf("%w: %q has no .git, so an edit there would never be committed",
|
||||||
|
ErrNotSyncSource, brainDir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package notes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newBrain lays out a tree shaped like the live one: a git dir, note dirs,
|
||||||
|
// and the non-note areas that must never be reachable.
|
||||||
|
func newBrain(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, d := range []string{
|
||||||
|
".git/hooks", "knowledge", "wiki/homelab/facts", "archive/claude-sessions/koala",
|
||||||
|
"sessions", "raw", ".obsidian",
|
||||||
|
} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write := func(p, s string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, p), []byte(s), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("CLAUDE.md", "# instructions\n")
|
||||||
|
write("log.md", "ignored scratch\n")
|
||||||
|
write("knowledge/note.md", "# note\n")
|
||||||
|
write("wiki/homelab/facts/f.md", "# fact\n")
|
||||||
|
write("wiki/homelab/_index.md", "# index\n")
|
||||||
|
write("archive/claude-sessions/koala/session-1.md", "verbatim transcript\n")
|
||||||
|
write("sessions/s.jsonl", "{}\n")
|
||||||
|
write(".git/hooks/post-commit", "#!/bin/sh\n")
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_AcceptsNotePaths(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
for _, p := range []string{"knowledge/note.md", "wiki/homelab/facts/f.md"} {
|
||||||
|
got, err := Resolve(dir, p, ScopeNotes)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Resolve(%q): %v", p, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got.Abs, dir) {
|
||||||
|
t.Errorf("Resolve(%q).Abs = %q, want inside %q", p, got.Abs, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_RefusesEscapesAndNonNoteAreas(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
|
||||||
|
// Canonicalisation must happen BEFORE the allowlist check. A prefix test
|
||||||
|
// on the raw argument accepts every one of these: "knowledge/..%2f" style
|
||||||
|
// inputs collapse to .git, and .git/hooks/post-commit is executed by
|
||||||
|
// brain-sync inside a pod holding the Gitea push credentials.
|
||||||
|
refuse := []string{
|
||||||
|
"knowledge/../.git/hooks/post-commit",
|
||||||
|
"wiki/../.git/config",
|
||||||
|
"./knowledge/../.git/config",
|
||||||
|
"knowledge//../.git/config",
|
||||||
|
"../../etc/passwd",
|
||||||
|
"/etc/passwd",
|
||||||
|
".git/config",
|
||||||
|
"archive/claude-sessions/koala/session-1.md", // verbatim transcripts
|
||||||
|
"sessions/s.jsonl",
|
||||||
|
"raw/x.md",
|
||||||
|
".obsidian/workspace.json",
|
||||||
|
"log.md", // gitignored: an edit here would never sync (brain#2)
|
||||||
|
"wiki/homelab/_index.md", // generated; brain-sync resolves it in origin's favour
|
||||||
|
"knowledge/note.txt", // not markdown
|
||||||
|
"CLAUDE.md", // root file — not reachable in the notes scope
|
||||||
|
}
|
||||||
|
for _, p := range refuse {
|
||||||
|
if _, err := Resolve(dir, p, ScopeNotes); err == nil {
|
||||||
|
t.Errorf("Resolve(%q) = nil error, want refusal", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_RootScopeAdmitsOnlyNamedFiles(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
|
||||||
|
if _, err := Resolve(dir, "CLAUDE.md", ScopeRoot); err != nil {
|
||||||
|
t.Errorf("Resolve(CLAUDE.md, ScopeRoot): %v", err)
|
||||||
|
}
|
||||||
|
// log.md is the reason this is an exact-name list and not "root *.md":
|
||||||
|
// it is gitignored, so an edit would apply locally and never reach gitea.
|
||||||
|
for _, p := range []string{"log.md", "AGENTS.md", "knowledge/note.md", ".git/config"} {
|
||||||
|
if _, err := Resolve(dir, p, ScopeRoot); err == nil {
|
||||||
|
t.Errorf("Resolve(%q, ScopeRoot) = nil error, want refusal", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_RefusesSymlinkedTargets(t *testing.T) {
|
||||||
|
dir := newBrain(t)
|
||||||
|
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||||
|
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The tree is Syncthing-managed, so a symlink can appear without anyone
|
||||||
|
// adding one deliberately. Containment must survive it.
|
||||||
|
if err := os.Symlink(outside, filepath.Join(dir, "knowledge/link.md")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Resolve(dir, "knowledge/link.md", ScopeNotes); err == nil {
|
||||||
|
t.Error("Resolve followed a symlink out of the brain dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(filepath.Dir(outside), filepath.Join(dir, "wiki/escape")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Resolve(dir, "wiki/escape/secret.md", ScopeNotes); err == nil {
|
||||||
|
t.Error("Resolve followed a symlinked directory out of the brain dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_RefusesWhenTheTreeIsNotTheSyncSource(t *testing.T) {
|
||||||
|
// brain#2 was "wrote to a directory the sync does not commit from". No
|
||||||
|
// code review prevents that recurring — a wrong INGEST_BRAIN_DIR in a
|
||||||
|
// future manifest reintroduces it and every unit test still passes. So
|
||||||
|
// assert the property at runtime, the way brain-sync itself does.
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "knowledge/note.md"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Reads are fine anywhere; the guard belongs to writes.
|
||||||
|
if _, err := Resolve(dir, "knowledge/note.md", ScopeNotes); err != nil {
|
||||||
|
t.Fatalf("Resolve should not require .git for a read: %v", err)
|
||||||
|
}
|
||||||
|
_, err := Edit(dir, "knowledge/note.md", "x", "y", ScopeNotes)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Edit accepted a tree with no .git — the write would never be committed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "sync source") {
|
||||||
|
t.Errorf("error should name the cause, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/textutil"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -427,8 +428,5 @@ func excerpt(content string, maxLen int) string {
|
|||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
body = strings.TrimSpace(parts[2])
|
body = strings.TrimSpace(parts[2])
|
||||||
}
|
}
|
||||||
if len(body) > maxLen {
|
return textutil.Truncate(body, maxLen)
|
||||||
return body[:maxLen] + "…"
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Package startupretry retries a startup dependency that may not be serving
|
||||||
|
// yet, instead of exiting on its first failure.
|
||||||
|
//
|
||||||
|
// It exists for the cold-boot ordering race (#83): on a cluster restart the
|
||||||
|
// ingestion pod can reach OIDC discovery before dex is answering, gets a 502
|
||||||
|
// from ingress, and dies — after having already connected postgres, backfilled
|
||||||
|
// the graph and ingested session batches, all of which the restart redoes.
|
||||||
|
// Nothing is misconfigured in that scenario; the pod simply started first.
|
||||||
|
//
|
||||||
|
// The budget is bounded on purpose. Retrying forever would turn a genuine
|
||||||
|
// misconfiguration (wrong issuer URL, bad audience) into a process that looks
|
||||||
|
// healthy and never serves, which is harder to diagnose than a crash loop.
|
||||||
|
package startupretry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Policy bounds the retry: at most Attempts calls, with the delay doubling
|
||||||
|
// from Base and capped at Max.
|
||||||
|
type Policy struct {
|
||||||
|
Attempts int // total calls, including the first; < 1 means 1
|
||||||
|
Base time.Duration // delay before the second attempt
|
||||||
|
Max time.Duration // ceiling for the doubling delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do calls fn until it succeeds, the attempt budget is spent, or ctx ends.
|
||||||
|
//
|
||||||
|
// The returned error wraps the last failure, so callers keep the diagnosis
|
||||||
|
// they would have had without the retry.
|
||||||
|
func Do[T any](ctx context.Context, p Policy, fn func(context.Context) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
|
||||||
|
attempts := p.Attempts
|
||||||
|
if attempts < 1 {
|
||||||
|
attempts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
delay := p.Base
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for i := range attempts {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return zero, fmt.Errorf("startupretry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := fn(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
if i == attempts-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if delay <= 0 {
|
||||||
|
delay = time.Second
|
||||||
|
}
|
||||||
|
if p.Max > 0 && delay > p.Max {
|
||||||
|
delay = p.Max
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return zero, fmt.Errorf("startupretry: %w", ctx.Err())
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
delay *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return zero, fmt.Errorf("startupretry: gave up after %d attempts: %w", attempts, lastErr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// ingestion/internal/startupretry/startupretry_test.go
|
||||||
|
package startupretry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fastPolicy(attempts int) Policy {
|
||||||
|
return Policy{Attempts: attempts, Base: time.Millisecond, Max: 2 * time.Millisecond}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
|
||||||
|
calls++
|
||||||
|
return "ready", nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
if got != "ready" {
|
||||||
|
t.Errorf("got %q, want %q", got, "ready")
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("calls = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
|
||||||
|
// The observed cold-boot race: dex is not serving yet, ingress answers
|
||||||
|
// 502 for the first few attempts, then discovery works.
|
||||||
|
calls := 0
|
||||||
|
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
|
||||||
|
calls++
|
||||||
|
if calls < 4 {
|
||||||
|
return "", errors.New("oidc discovery: status 502")
|
||||||
|
}
|
||||||
|
return "ready", nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
|
||||||
|
}
|
||||||
|
if got != "ready" {
|
||||||
|
t.Errorf("got %q, want %q", got, "ready")
|
||||||
|
}
|
||||||
|
if calls != 4 {
|
||||||
|
t.Errorf("calls = %d, want 4", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDo_GivesUpAfterTheAttemptBudgetAndReportsTheLastError(t *testing.T) {
|
||||||
|
// A genuinely broken issuer must still fail — retrying forever would turn
|
||||||
|
// a config error into a silent hang, which is worse than crash-looping.
|
||||||
|
last := errors.New("oidc discovery: status 404")
|
||||||
|
calls := 0
|
||||||
|
|
||||||
|
_, err := Do(context.Background(), fastPolicy(3), func(context.Context) (string, error) {
|
||||||
|
calls++
|
||||||
|
return "", last
|
||||||
|
})
|
||||||
|
|
||||||
|
if !errors.Is(err, last) {
|
||||||
|
t.Fatalf("err = %v, want it to wrap the last failure", err)
|
||||||
|
}
|
||||||
|
if calls != 3 {
|
||||||
|
t.Errorf("calls = %d, want 3 (the attempt budget)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDo_StopsPromptlyWhenTheContextIsCancelled(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
calls := 0
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var err error
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
_, err = Do(ctx, Policy{Attempts: 100, Base: 50 * time.Millisecond, Max: time.Second},
|
||||||
|
func(context.Context) (string, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return "", errors.New("still down")
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Do did not return after context cancellation")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDo_BacksOffAndIsCappedByMax(t *testing.T) {
|
||||||
|
var delays []time.Duration
|
||||||
|
p := Policy{Attempts: 5, Base: 10 * time.Millisecond, Max: 25 * time.Millisecond}
|
||||||
|
|
||||||
|
last := time.Now()
|
||||||
|
_, _ = Do(context.Background(), p, func(context.Context) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
delays = append(delays, now.Sub(last))
|
||||||
|
last = now
|
||||||
|
return "", errors.New("down")
|
||||||
|
})
|
||||||
|
|
||||||
|
// delays[0] is the first call (no wait); the rest are the sleeps.
|
||||||
|
if len(delays) != 5 {
|
||||||
|
t.Fatalf("attempts = %d, want 5", len(delays))
|
||||||
|
}
|
||||||
|
if delays[1] >= delays[2] {
|
||||||
|
t.Errorf("backoff is not growing: %v then %v", delays[1], delays[2])
|
||||||
|
}
|
||||||
|
for i, d := range delays[1:] {
|
||||||
|
if d > 2*p.Max {
|
||||||
|
t.Errorf("delay %d = %v, exceeds the Max cap %v with slack", i+1, d, p.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_ZeroValueStillMakesOneAttempt(t *testing.T) {
|
||||||
|
// A caller that forgets to configure the policy must not silently get a
|
||||||
|
// no-op that returns a nil validator and a nil error.
|
||||||
|
calls := 0
|
||||||
|
_, err := Do(context.Background(), Policy{}, func(context.Context) (string, error) {
|
||||||
|
calls++
|
||||||
|
return "", errors.New("down")
|
||||||
|
})
|
||||||
|
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("calls = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Error("err = nil, want the failure surfaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Package textutil holds small text helpers shared across the ingestion
|
||||||
|
// service.
|
||||||
|
package textutil
|
||||||
|
|
||||||
|
import "unicode/utf8"
|
||||||
|
|
||||||
|
// Ellipsis marks a value that was cut short.
|
||||||
|
const Ellipsis = "…"
|
||||||
|
|
||||||
|
// Truncate shortens s to at most limit CHARACTERS, appending an ellipsis when
|
||||||
|
// anything was dropped.
|
||||||
|
//
|
||||||
|
// It exists because four call sites did `s[:limit] + "…"` (#93). Both halves
|
||||||
|
// of that are byte operations: len() counts bytes and a slice cuts bytes, so
|
||||||
|
// on Swedish text — where å ä ö are two bytes and an em dash is three — the
|
||||||
|
// cut lands mid-character and leaves an orphaned lead byte. The result is
|
||||||
|
// invalid UTF-8.
|
||||||
|
//
|
||||||
|
// That failed loudly in exactly one place, wiki/index.md, which is written
|
||||||
|
// raw to disk and then rejected by every strict decoder. The other three fed
|
||||||
|
// MCP responses, where encoding/json silently substitutes U+FFFD — so those
|
||||||
|
// had been quietly eating the last character of truncated excerpts with no
|
||||||
|
// error anywhere.
|
||||||
|
//
|
||||||
|
// The marker is appended only when content was actually dropped: an ellipsis
|
||||||
|
// on complete text is a lie about the content.
|
||||||
|
func Truncate(s string, limit int) string {
|
||||||
|
if limit <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(s) <= limit {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk runes rather than converting to []rune: the input can be a whole
|
||||||
|
// note body, and this runs on the read path.
|
||||||
|
count := 0
|
||||||
|
for i := range s {
|
||||||
|
if count == limit {
|
||||||
|
return s[:i] + Ellipsis
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return s + Ellipsis
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// ingestion/internal/textutil/truncate_test.go
|
||||||
|
package textutil_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/textutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTruncate_ShortStringsAreUntouched(t *testing.T) {
|
||||||
|
for _, s := range []string{"", "kort", "räv", "a — b"} {
|
||||||
|
if got := textutil.Truncate(s, 100); got != s {
|
||||||
|
t.Errorf("Truncate(%q, 100) = %q, want it unchanged", s, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate_NeverCutsInsideACharacter(t *testing.T) {
|
||||||
|
// The property, not an example: whatever the limit, the result must be
|
||||||
|
// valid UTF-8. Byte-slicing Swedish text (å ä ö) or an em dash produces
|
||||||
|
// an orphaned lead byte, which is what corrupted wiki/index.md (#93).
|
||||||
|
s := strings.Repeat("räv — ål — ökäd ", 20)
|
||||||
|
|
||||||
|
for limit := 0; limit <= len(s)+5; limit++ {
|
||||||
|
got := textutil.Truncate(s, limit)
|
||||||
|
if !utf8.ValidString(got) {
|
||||||
|
t.Fatalf("Truncate(s, %d) produced invalid UTF-8: %q", limit, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate_CountsCharactersNotBytes(t *testing.T) {
|
||||||
|
// "räv" is 4 bytes, 3 runes. A byte-based limit of 3 would cut inside the
|
||||||
|
// "ä"; a rune-based one returns it whole.
|
||||||
|
if got := textutil.Truncate("räv", 3); got != "räv" {
|
||||||
|
t.Errorf("Truncate(\"räv\", 3) = %q, want the whole word", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := textutil.Truncate("räven springer", 5)
|
||||||
|
if utf8.RuneCountInString(got) != 6 { // 5 runes + the ellipsis
|
||||||
|
t.Errorf("Truncate(...) = %q, want 5 runes plus the marker", got)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "…") {
|
||||||
|
t.Errorf("Truncate(...) = %q, want it marked as truncated", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate_MarksOnlyWhenSomethingWasDropped(t *testing.T) {
|
||||||
|
// An ellipsis on text that was not cut is a lie about the content.
|
||||||
|
if got := textutil.Truncate("räv", 10); strings.HasSuffix(got, "…") {
|
||||||
|
t.Errorf("Truncate(%q, 10) = %q, want no truncation marker", "räv", got)
|
||||||
|
}
|
||||||
|
if got := textutil.Truncate("räv", 3); strings.HasSuffix(got, "…") {
|
||||||
|
t.Errorf("exact-length input must not be marked: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate_NonPositiveLimitYieldsNothingRatherThanPanicking(t *testing.T) {
|
||||||
|
for _, limit := range []int{0, -1, -100} {
|
||||||
|
if got := textutil.Truncate("räven", limit); got != "" {
|
||||||
|
t.Errorf("Truncate(_, %d) = %q, want empty", limit, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
// reachable database.
|
// reachable database.
|
||||||
//
|
//
|
||||||
// To run:
|
// To run:
|
||||||
|
//
|
||||||
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
||||||
// go test ./internal/vectorstore/... -run Integration
|
// go test ./internal/vectorstore/... -run Integration
|
||||||
func dsn(t *testing.T) string {
|
func dsn(t *testing.T) string {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/textutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RebuildIndex writes brain/wiki/index.md from the current wiki contents.
|
// RebuildIndex writes brain/wiki/index.md from the current wiki contents.
|
||||||
@@ -62,10 +64,7 @@ func pageFirstSentence(brainDir string, e Entry) string {
|
|||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(line) > 100 {
|
return textutil.Truncate(line, 100)
|
||||||
return line[:100] + "…"
|
|
||||||
}
|
|
||||||
return line
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ skills:
|
|||||||
func writeModels(t *testing.T, content string) string {
|
func writeModels(t *testing.T, content string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
f := filepath.Join(t.TempDir(), "models.yaml")
|
f := filepath.Join(t.TempDir(), "models.yaml")
|
||||||
require.NoError(t, os.WriteFile(f, []byte(content), 0644))
|
require.NoError(t, os.WriteFile(f, []byte(content), 0o644))
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func (s stubSkill) Tools() []registry.ToolDef {
|
|||||||
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s stubSkill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
func (s stubSkill) Handle(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
|
||||||
return json.RawMessage(`{"ok":true}`), nil
|
return json.RawMessage(`{"ok":true}`), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-18
@@ -5,15 +5,29 @@
|
|||||||
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
|
||||||
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
# For everything else: concatenates root AGENT.md + project PROJECT.md
|
||||||
#
|
#
|
||||||
# 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
|
# Task: task context:sync
|
||||||
#
|
#
|
||||||
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Parse --force flag and collect adapter names separately
|
||||||
|
FORCE=false
|
||||||
|
ADAPTERS=()
|
||||||
|
for _arg in "$@"; do
|
||||||
|
case "$_arg" in
|
||||||
|
--force) FORCE=true ;;
|
||||||
|
*) ADAPTERS+=("$_arg") ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
PROJECT_FILE=".context/PROJECT.md"
|
PROJECT_FILE=".context/PROJECT.md"
|
||||||
SKILLS_DIR=".skills"
|
|
||||||
|
|
||||||
# Walk up to find root .context/AGENT.md
|
# Walk up to find root .context/AGENT.md
|
||||||
find_root_context() {
|
find_root_context() {
|
||||||
@@ -36,19 +50,28 @@ if [ ! -f "$PROJECT_FILE" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Pre-flight: reject unfilled {{...}} placeholders unless --force
|
||||||
|
if [ "$FORCE" = false ]; then
|
||||||
|
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
|
||||||
|
if [ -n "$_placeholders" ]; then
|
||||||
|
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
|
||||||
|
while IFS= read -r _match; do
|
||||||
|
_lineno="${_match%%:*}"
|
||||||
|
_content="${_match#*:}"
|
||||||
|
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
|
||||||
|
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
|
||||||
|
done <<< "$_placeholders"
|
||||||
|
echo "" >&2
|
||||||
|
echo "Fill these placeholders, then re-run: task context:sync" >&2
|
||||||
|
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
echo " Root context: $ROOT_CONTEXT"
|
echo " Root context: $ROOT_CONTEXT"
|
||||||
else
|
else
|
||||||
# No reachable root AGENT.md — common in CI's clean checkout. The root+project
|
echo " No root AGENT.md found (project context only)"
|
||||||
# adapters (AGENTS.md, system-prompt.txt) require the root context to
|
|
||||||
# regenerate correctly, so we skip them entirely and only regenerate CLAUDE.md
|
|
||||||
# (which is project-only and inherits root via tree walk in Claude Code itself).
|
|
||||||
echo " No root AGENT.md found — regenerating CLAUDE.md only"
|
|
||||||
echo "Syncing project context from $PROJECT_FILE..."
|
|
||||||
cat "$PROJECT_FILE" > CLAUDE.md
|
|
||||||
echo " → CLAUDE.md (project-only)"
|
|
||||||
echo "Done."
|
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Emit root context + separator
|
# Emit root context + separator
|
||||||
@@ -95,6 +118,7 @@ generate_system_prompt() {
|
|||||||
|
|
||||||
# ── MCP config ───────────────────────────────────────────────
|
# ── MCP config ───────────────────────────────────────────────
|
||||||
generate_mcp() {
|
generate_mcp() {
|
||||||
|
# Ensure baseline file exists with project-specific knowledge server
|
||||||
if [ ! -f .context/mcp.json ]; then
|
if [ ! -f .context/mcp.json ]; then
|
||||||
cat > .context/mcp.json << 'JSON'
|
cat > .context/mcp.json << 'JSON'
|
||||||
{
|
{
|
||||||
@@ -106,27 +130,47 @@ generate_mcp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
echo " → .context/mcp.json (new)"
|
|
||||||
else
|
|
||||||
echo " → .context/mcp.json (exists, skipped)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Merge root mcp-servers.json if found alongside root AGENT.md
|
||||||
|
local root_mcp=""
|
||||||
|
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
|
||||||
|
local candidate
|
||||||
|
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
|
||||||
|
[ -f "$candidate" ] && root_mcp="$candidate"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$root_mcp" ]; then
|
||||||
|
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Root servers take precedence over project entries on key conflict
|
||||||
|
local root_servers count updated
|
||||||
|
root_servers=$(jq '.servers' "$root_mcp")
|
||||||
|
count=$(printf '%s' "$root_servers" | jq 'keys | length')
|
||||||
|
updated=$(jq --argjson root "$root_servers" \
|
||||||
|
'.mcpServers = (.mcpServers + $root)' \
|
||||||
|
.context/mcp.json)
|
||||||
|
printf '%s\n' "$updated" > .context/mcp.json
|
||||||
|
echo " → .context/mcp.json (merged $count root servers)"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Syncing project context from $PROJECT_FILE..."
|
echo "Syncing project context from $PROJECT_FILE..."
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
if [ ${#ADAPTERS[@]} -eq 0 ]; then
|
||||||
generate_claude
|
generate_claude
|
||||||
generate_agents
|
generate_agents
|
||||||
generate_system_prompt
|
generate_system_prompt
|
||||||
generate_mcp
|
generate_mcp
|
||||||
else
|
else
|
||||||
for adapter in "$@"; do
|
for adapter in "${ADAPTERS[@]}"; do
|
||||||
case "$adapter" in
|
case "$adapter" in
|
||||||
claude) generate_claude ;;
|
claude) generate_claude ;;
|
||||||
agents) generate_agents ;;
|
agents) generate_agents ;;
|
||||||
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
prompt|system|openwebui|owui|generic) generate_system_prompt ;;
|
||||||
mcp) generate_mcp ;;
|
mcp) generate_mcp ;;
|
||||||
*) echo "Unknown adapter: $adapter" ;;
|
*) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -42,34 +42,35 @@ These actions are **carried into the Phase 4 capture call** as `tickets[]` rathe
|
|||||||
|
|
||||||
## Phase 4 — Capture (one uniform call)
|
## Phase 4 — Capture (one uniform call)
|
||||||
|
|
||||||
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets, summary → ai-sessions — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
|
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
|
||||||
|
|
||||||
|
**Note:** capture no longer writes a session summary anywhere (the `summary` → `ai-sessions` write path was removed — it produced a frontmatter shape `ai-sessions`' own pipeline couldn't parse, silently invisible to that repo's own audit tooling; see `ai-sessions#13`). If the session's decisions/artifacts are worth a durable narrative beyond the `insights[]` this skill writes to the brain, that's a separate, explicit call — not something this skill does implicitly.
|
||||||
|
|
||||||
**Assemble one payload:**
|
**Assemble one payload:**
|
||||||
|
|
||||||
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
|
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
|
||||||
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
|
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
|
||||||
- **`summary`** — `{title, body, repos_touched}`. Capture writes it to `ai-sessions` and stamps `fidelity` in frontmatter. Body stays reconstructable: one-paragraph summary, decisions, key artifacts, open threads.
|
|
||||||
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
|
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
|
||||||
|
|
||||||
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
|
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
|
||||||
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`, and every entry in `summary.repos_touched`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
|
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
|
||||||
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
|
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
|
||||||
- Declare `context.classification: "internal"` for normal homelab work.
|
- Declare `context.classification: "internal"` for normal homelab work.
|
||||||
- `summary.repos_touched`, insight `wing`s, and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged. The summary always lands in `ai-sessions` (internal), so the summary path itself never escalates.
|
- Insight `wing`s and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged.
|
||||||
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
|
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
|
||||||
|
|
||||||
**GATE — dry-run first, then execute.**
|
**GATE — dry-run first, then execute.**
|
||||||
1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing.
|
1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing.
|
||||||
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets/summary that would land) and get explicit confirmation.
|
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets that would land) and get explicit confirmation.
|
||||||
3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed.
|
3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed.
|
||||||
|
|
||||||
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`gitea:file_write_branch` summary + `brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
|
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
|
||||||
|
|
||||||
## Phase 5 — Verdict
|
## Phase 5 — Verdict
|
||||||
|
|
||||||
Deliver a final "safe to archive" verdict in the chat. Either:
|
Deliver a final "safe to archive" verdict in the chat. Either:
|
||||||
|
|
||||||
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, summary path, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
||||||
- **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it.
|
- **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it.
|
||||||
|
|
||||||
Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
|
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
|
||||||
privileged cross-harness write path touching brain + Gitea + ai-sessions.
|
privileged cross-harness write path touching brain + Gitea + ai-sessions.
|
||||||
|
**Superseded 2026-07-27:** the ai-sessions summary write path specified below was removed post-ship
|
||||||
|
(see `specs/capture-implementation-report.md`). `capture` now touches brain + Gitea only.
|
||||||
**Tracks:** hyperguild #49.
|
**Tracks:** hyperguild #49.
|
||||||
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in
|
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in
|
||||||
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
# Capture capability — implementation report (as-built)
|
# Capture capability — implementation report (as-built)
|
||||||
|
|
||||||
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50–#55) closed.
|
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50–#55) closed.
|
||||||
|
**Superseded 2026-07-27:** the `summary` → `ai-sessions` write path documented below was removed.
|
||||||
|
It wrote a frontmatter shape (`title`/`harness`/`fidelity`/`captured_at`/`repos_touched`) that
|
||||||
|
`ai-sessions`' own extract/audit pipeline couldn't parse — invisible to that repo's own audit
|
||||||
|
tooling and bypassing its redaction/completeness gates (`ai-sessions#13`). `capture` now persists
|
||||||
|
insights → brain and action items → Gitea tickets only. This document is kept as the historical
|
||||||
|
as-built record of what shipped in #49; treat every `summary`/ai-sessions reference below as
|
||||||
|
retired, not current behaviour.
|
||||||
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
|
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
|
||||||
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.
|
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user