Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9345a0a867 | ||
|
|
a5a328fea5 | ||
|
|
f450a6c84f | ||
|
|
5a5d938e8e | ||
|
|
cc86a74dc5 | ||
|
|
6e449b464c | ||
|
|
868fe28c96 | ||
|
|
6e69698343 | ||
|
|
eb2acfeeb8 | ||
|
|
6e5a9cfce3 | ||
|
|
25b79f057e | ||
|
|
47bbac4b9f | ||
|
|
8a27ead91c | ||
|
|
1a9be0cfb1 |
@@ -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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Supervisor MCP server
|
# Supervisor MCP server
|
||||||
SUPERVISOR_PORT=3200
|
SUPERVISOR_PORT=3200
|
||||||
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
SUPERVISOR_CONFIG_DIR=./config/supervisor
|
||||||
SUPERVISOR_MODELS_FILE=./config/models.yaml
|
|
||||||
|
|
||||||
# LiteLLM gateway (iguana)
|
# LiteLLM gateway (iguana)
|
||||||
LITELLM_BASE_URL=http://iguana:4000
|
LITELLM_BASE_URL=http://iguana:4000
|
||||||
|
|||||||
+13
-5
@@ -81,11 +81,19 @@ jobs:
|
|||||||
git config user.email "cd-bot@d-ma.be"
|
git config user.email "cd-bot@d-ma.be"
|
||||||
git config user.name "CD Bot"
|
git config user.name "CD Bot"
|
||||||
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
git add "k3s/apps/supervisor/ingestion-deployment.yaml"
|
||||||
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
# Idempotent: `git commit` exits 1 on an empty index, so a run that
|
||||||
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
# finds the bump already applied failed the whole job and rang the
|
||||||
git push
|
# CI-red alarm for a deploy that was fine (hyperguild#118). Two
|
||||||
|
# sources of a no-op run: a manual re-run, and the tag-push CI run
|
||||||
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
# that used to shadow the branch-push one for the same sha.
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Infra already at ${IMAGE_TAG} — nothing to commit"
|
||||||
|
else
|
||||||
|
git commit -m "chore(deploy): ingestion → ${IMAGE_TAG}"
|
||||||
|
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
|
||||||
|
git push
|
||||||
|
echo "Infra repo updated: ingestion → ${IMAGE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Trigger Flux reconcile (immediate)
|
- name: Trigger Flux reconcile (immediate)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ name: CI
|
|||||||
|
|
||||||
"on":
|
"on":
|
||||||
push:
|
push:
|
||||||
|
# Branch only. A `v*` tag here points at a commit main already tested, so
|
||||||
|
# the tag push re-ran the whole gate and — because cd.yml keys off "CI
|
||||||
|
# succeeded" — triggered a SECOND full image build and deploy for the same
|
||||||
|
# sha. That second deploy then died on "nothing to commit" (hyperguild#118).
|
||||||
|
# Nothing consumes a tag-triggered run: there is no release workflow, and
|
||||||
|
# cd.yml reads github.sha, not the tag.
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
ingestion: cd ingestion && INGEST_BRAIN_DIR=../brain INGEST_PORT=3300 INGEST_WATCH_INTERVAL=30 go run ./cmd/server/
|
||||||
supervisor: SUPERVISOR_CONFIG_DIR=./config/supervisor SUPERVISOR_MODELS_FILE=./config/models.yaml SUPERVISOR_SESSIONS_DIR=./brain/sessions INGEST_BASE_URL=http://localhost:3300 INGEST_SVC_URL=http://localhost:3300 go run ./cmd/supervisor/
|
|
||||||
|
|||||||
@@ -76,6 +76,64 @@ that prompted the question is answered by naming it here instead.
|
|||||||
Full reasoning, including the reversal trigger: brain
|
Full reasoning, including the reversal trigger: brain
|
||||||
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
|
||||||
|
|
||||||
|
## Ingest revises, it does not overwrite
|
||||||
|
|
||||||
|
Re-ingesting a source **merges into the page already on disk** rather than
|
||||||
|
replacing it. Previously the write was unconditional, so a second ingest of the
|
||||||
|
same source discarded everything accumulated since — including hand-written
|
||||||
|
sections.
|
||||||
|
|
||||||
|
The merge is deliberately asymmetric in the existing page's favour:
|
||||||
|
|
||||||
|
| On the page | In the fresh extraction | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| a section | the same section | **the on-disk version wins** |
|
||||||
|
| — | a new section | added |
|
||||||
|
| a bullet list (`Sources`, `Key Claims`, …) | more bullets | unioned, no duplicates |
|
||||||
|
| an append section (`Open Questions`, `Updates`, …) | more lines | only genuinely new lines appended |
|
||||||
|
| frontmatter | frontmatter | **the on-disk frontmatter wins** |
|
||||||
|
|
||||||
|
So an extraction can add to a note but never silently rewrite what a human put
|
||||||
|
there, which is what makes it safe to run on every ingest rather than behind a
|
||||||
|
review gate.
|
||||||
|
|
||||||
|
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
||||||
|
does not commit noise that hides real edits.
|
||||||
|
|
||||||
|
Two fields are **generator-owned** and do get refreshed on a revise:
|
||||||
|
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
||||||
|
an explicit allowlist, so a field nobody has thought about stays human-owned.
|
||||||
|
|
||||||
|
### Disagreement is reported, not resolved
|
||||||
|
|
||||||
|
When the fresh extraction produces a *different version* of a section that
|
||||||
|
already exists, the on-disk version still wins — but the run now says so:
|
||||||
|
|
||||||
|
```
|
||||||
|
wiki/sources/shape-up.md: source now disagrees on section "Summary" — on-disk version kept
|
||||||
|
```
|
||||||
|
|
||||||
|
That line lands in `log.md` with the rest of the run's warnings. Without it, "the
|
||||||
|
source is unchanged" and "the source changed its mind and we ignored it" look
|
||||||
|
identical from the outside.
|
||||||
|
|
||||||
|
This is deliberately **textual** disagreement, not semantic contradiction: cheap,
|
||||||
|
deterministic, no model on the write path. Deciding whether a disagreement is a
|
||||||
|
genuine contradiction stays with the Judge-gated `brain-enrich` pass, which is
|
||||||
|
approval-gated by design. Detection here, adjudication there.
|
||||||
|
|
||||||
|
## Re-ingesting an unchanged source is skipped
|
||||||
|
|
||||||
|
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
||||||
|
excluded — on the source page. A later ingest of the same source compares it
|
||||||
|
first and skips extraction entirely when it matches, reporting the skip rather
|
||||||
|
than doing it silently.
|
||||||
|
|
||||||
|
Body-only is deliberate: a re-crawl that stamps a new fetch date has not changed
|
||||||
|
the source, and treating that as drift would re-extract the corpus every time a
|
||||||
|
crawler ran. A missing or unreadable hash always means "extract" — a missed skip
|
||||||
|
costs one extraction, a wrong skip loses an update.
|
||||||
|
|
||||||
## Phase 1 tools (available now)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|
|||||||
+9
-26
@@ -21,7 +21,7 @@ tasks:
|
|||||||
# ── Development ────────────────────────────────────────────────────────────
|
# ── Development ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
start:
|
start:
|
||||||
desc: Start ingestion + supervisor (requires goreman — go install github.com/mattn/goreman@latest)
|
desc: Start ingestion (requires goreman — go install github.com/mattn/goreman@latest)
|
||||||
cmds:
|
cmds:
|
||||||
- goreman start
|
- goreman start
|
||||||
|
|
||||||
@@ -32,11 +32,6 @@ tasks:
|
|||||||
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
- lsof -ti:3200 | xargs kill -9 2>/dev/null || true
|
||||||
- echo "hyperguild stopped"
|
- echo "hyperguild stopped"
|
||||||
|
|
||||||
supervisor:dev:
|
|
||||||
desc: Run supervisor MCP server (development)
|
|
||||||
cmds:
|
|
||||||
- go run ./cmd/supervisor
|
|
||||||
|
|
||||||
hyperguild:dev:
|
hyperguild:dev:
|
||||||
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier)
|
||||||
cmds:
|
cmds:
|
||||||
@@ -67,14 +62,9 @@ tasks:
|
|||||||
build:
|
build:
|
||||||
desc: Build all binaries
|
desc: Build all binaries
|
||||||
cmds:
|
cmds:
|
||||||
- task: supervisor:build
|
- task: hyperguild:build
|
||||||
- task: ingestion:build
|
- task: ingestion:build
|
||||||
|
|
||||||
supervisor:build:
|
|
||||||
desc: Build supervisor binary
|
|
||||||
cmds:
|
|
||||||
- go build -trimpath -ldflags="-s -w -X main.version={{.VERSION}}" -o bin/supervisor ./cmd/supervisor
|
|
||||||
|
|
||||||
ingestion:build:
|
ingestion:build:
|
||||||
desc: Build ingestion server binary
|
desc: Build ingestion server binary
|
||||||
dir: ingestion
|
dir: ingestion
|
||||||
@@ -84,7 +74,7 @@ tasks:
|
|||||||
# ── Quality ────────────────────────────────────────────────────────────────
|
# ── Quality ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
check:
|
check:
|
||||||
desc: Run all checks (context freshness + lint + test + vet) across all modules
|
desc: Run all checks (context freshness + lint + test + vet + build) across all modules
|
||||||
cmds:
|
cmds:
|
||||||
# Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
|
# 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
|
# rest of the estate. It is not a preference: context-sync.sh walks UP
|
||||||
@@ -120,6 +110,12 @@ tasks:
|
|||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
|
# `build` is in the gate deliberately (hyperguild#120). vet and test walk
|
||||||
|
# ./... , which enumerates the packages that EXIST — so a build target
|
||||||
|
# naming a DELETED package is invisible to both. `task build` pointed at
|
||||||
|
# ./cmd/supervisor for months after that directory was removed, and CI
|
||||||
|
# stayed green the whole time because nothing in check ever built.
|
||||||
|
- task: build
|
||||||
- task: security:gitleaks
|
- task: security:gitleaks
|
||||||
|
|
||||||
# ── Security ─────────────────────────────────────────────
|
# ── Security ─────────────────────────────────────────────
|
||||||
@@ -173,19 +169,6 @@ tasks:
|
|||||||
- govulncheck ./... || true
|
- govulncheck ./... || true
|
||||||
- cd ingestion && go vet ./...
|
- cd ingestion && go vet ./...
|
||||||
|
|
||||||
supervisor:test:smoke:
|
|
||||||
desc: Smoke test supervisor via MCP (requires start running)
|
|
||||||
cmds:
|
|
||||||
- |
|
|
||||||
curl -s -X POST http://localhost:${SUPERVISOR_PORT:-3200}/mcp \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq .
|
|
||||||
|
|
||||||
smoke:routing:
|
|
||||||
desc: Boot the routing pod against live LiteLLM + brain and verify _routing logs land
|
|
||||||
cmds:
|
|
||||||
- bash scripts/smoke-routing.sh
|
|
||||||
|
|
||||||
# ── Git / Release ──────────────────────────────────────────────────────────
|
# ── Git / Release ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
tag:
|
tag:
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Model selection — first entry per skill is used.
|
|
||||||
# Override per-call by passing model in the MCP tool args.
|
|
||||||
# Model names come from LiteLLM /v1/models (host/name format).
|
|
||||||
|
|
||||||
default_chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
|
|
||||||
skills:
|
|
||||||
tdd:
|
|
||||||
chain:
|
|
||||||
- koala/qwen3-coder-30b
|
|
||||||
review:
|
|
||||||
chain:
|
|
||||||
- iguana/devstral
|
|
||||||
debug:
|
|
||||||
chain:
|
|
||||||
- iguana/deepseek-r1-14b
|
|
||||||
spec:
|
|
||||||
chain:
|
|
||||||
- koala/phi4-14b
|
|
||||||
retrospective:
|
|
||||||
chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
trainer:
|
|
||||||
chain:
|
|
||||||
- iguana/qwen3-coder-next
|
|
||||||
@@ -396,15 +396,39 @@ func main() {
|
|||||||
}
|
}
|
||||||
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
host := envOr("CLAUDE_INGEST_HOST", systemHostname())
|
||||||
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
interval := time.Duration(envInt("CLAUDE_INGEST_INTERVAL", 60)) * time.Second
|
||||||
|
|
||||||
|
// Project directories excluded from ingestion entirely, as globs
|
||||||
|
// against the project directory name — e.g.
|
||||||
|
// "*cobalt-dingo*,*claude-worktrees-bridge-cse*".
|
||||||
|
//
|
||||||
|
// This is a veto list, deliberately named as one. It complements the
|
||||||
|
// client-name scrubber rather than replacing it: the scrubber drops
|
||||||
|
// individual TURNS whose content matches, while this never opens a
|
||||||
|
// project's files at all. Sessions working on live company financials
|
||||||
|
// are excluded here because their content is confidential in ways no
|
||||||
|
// pattern reliably recognises, and their learnings are captured
|
||||||
|
// deliberately through the session-close ritual instead.
|
||||||
|
//
|
||||||
|
// Caveat worth knowing: a project directory is derived from the
|
||||||
|
// session's WORKING DIRECTORY, not from the repositories it touches.
|
||||||
|
// A session run from ~/dev that happens to read a skipped repo will
|
||||||
|
// NOT be excluded by this. The scrubber remains the control for
|
||||||
|
// content; this is a control for whole workspaces.
|
||||||
|
skipProjects := splitList(os.Getenv("CLAUDE_INGEST_SKIP_PROJECTS"))
|
||||||
|
if len(skipProjects) > 0 {
|
||||||
|
logger.Info("claudewatcher: projects excluded from ingestion", "patterns", skipProjects)
|
||||||
|
}
|
||||||
|
|
||||||
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
sink := &claudeSink{brainDir: brainDir, logger: logger}
|
||||||
go func() {
|
go func() {
|
||||||
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
if err := claudewatcher.Watch(ctx, claudewatcher.Config{
|
||||||
SessionsDir: claudeDir,
|
SessionsDir: claudeDir,
|
||||||
Host: host,
|
Host: host,
|
||||||
Interval: interval,
|
Interval: interval,
|
||||||
Sink: sink,
|
Sink: sink,
|
||||||
Cursors: cursorStore,
|
Cursors: cursorStore,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
SkipProjects: skipProjects,
|
||||||
}); err != nil && err != context.Canceled {
|
}); err != nil && err != context.Canceled {
|
||||||
logger.Error("claudewatcher exited", "err", err)
|
logger.Error("claudewatcher exited", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -497,3 +497,45 @@ func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
|
|||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
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]]")
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
||||||
@@ -64,6 +65,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
|
||||||
@@ -90,14 +99,47 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
|||||||
}
|
}
|
||||||
priorHash = ContentHash(prior)
|
priorHash = ContentHash(prior)
|
||||||
|
|
||||||
|
// A caller may lead opts.Content with its own frontmatter fence to set or
|
||||||
|
// change fields on supersede (e.g. the claim envelope). Split it off
|
||||||
|
// before it reaches the body — left un-split, it would render as a
|
||||||
|
// second, inert `---`-fenced block of literal text rather than taking
|
||||||
|
// effect, and any claim-envelope fields inside it would reach disk
|
||||||
|
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
||||||
|
// writeHallNote must guard a change to the same fields).
|
||||||
|
contentFM, contentBody := parseFrontmatter(opts.Content)
|
||||||
|
|
||||||
fm, _ := parseFrontmatter(string(prior))
|
fm, _ := parseFrontmatter(string(prior))
|
||||||
|
for _, l := range contentFM.lines {
|
||||||
|
if l.key != "" {
|
||||||
|
fm.set(l.key, l.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
fm.set("supersedes", priorHash)
|
fm.set("supersedes", priorHash)
|
||||||
if opts.Reason != "" {
|
if opts.Reason != "" {
|
||||||
fm.set("supersede_reason", opts.Reason)
|
fm.set("supersede_reason", opts.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := []byte(fm.render() + opts.Content)
|
// Validate the envelope AFTER the merge, against the full field set that
|
||||||
|
// is about to reach disk — not just the caller's fragment. Validating the
|
||||||
|
// fragment alone would miss a caller that supersedes claim_provenance to
|
||||||
|
// empty while half_life/applies survive untouched from the prior note:
|
||||||
|
// each side looks individually fine, but the merged result is an
|
||||||
|
// unprovenanced envelope, which is the exact laundering this check
|
||||||
|
// exists to catch. (Caught in review before merge, not filed after —
|
||||||
|
// worth being honest about in the history: this second pass was the
|
||||||
|
// actual fix; the first one validated the wrong document.)
|
||||||
|
envelope := make(map[string]string, len(fm.lines))
|
||||||
|
for _, l := range fm.lines {
|
||||||
|
if l.key != "" {
|
||||||
|
envelope[l.key] = l.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := claim.ValidateEnvelope(envelope); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []byte(fm.render() + contentBody)
|
||||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||||
return "", "", "", fmt.Errorf("write: %w", err)
|
return "", "", "", fmt.Errorf("write: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,114 @@ func TestUpdateNoteSupersedesAndStamps(t *testing.T) {
|
|||||||
assert.Contains(t, s, "supersede_reason: facts changed")
|
assert.Contains(t, s, "supersede_reason: facts changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsInvalidEnvelopeAndWritesNothing(t *testing.T) {
|
||||||
|
// hyperguild#111: WriteNote validates the claim envelope before create;
|
||||||
|
// UpdateNote didn't before supersede, so the same rule must hold on
|
||||||
|
// change as on birth, fail-closed, nothing written on rejection.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\n# Old\n\nold body\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nhalf_life: forever\n---\n# New\n\nnew body\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsAppliesWithoutProvenance(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\n---\nold\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\napplies: universal\n---\nnew\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\n---\nold\n")
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nnew body\n",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "half_life: durable")
|
||||||
|
assert.Contains(t, s, "applies: universal")
|
||||||
|
assert.Contains(t, s, "claim_provenance: asserted")
|
||||||
|
assert.Contains(t, s, "wing: jepa-fx", "prior fields survive")
|
||||||
|
assert.Contains(t, s, "new body")
|
||||||
|
assert.Equal(t, 2, strings.Count(s, "---\n"),
|
||||||
|
"exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteRejectsClearingProvenanceWhileEnvelopeSurvives(t *testing.T) {
|
||||||
|
// Found in review, not written speculatively: validating only the
|
||||||
|
// caller's fragment (rather than the merged result) let a caller blank
|
||||||
|
// out claim_provenance alone while half_life/applies survived from the
|
||||||
|
// prior note untouched — each side looks fine on its own, the merged
|
||||||
|
// envelope on disk is the unprovenanced one the whole check exists to
|
||||||
|
// catch.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
original := "---\nwing: jepa-fx\nhall: facts\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nold\n"
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
|
||||||
|
|
||||||
|
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "---\nclaim_provenance: \n---\nnew body\n",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "claim envelope")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) {
|
||||||
|
// Regression pin: plain fence-less content (the common case) must behave
|
||||||
|
// exactly as it did before the envelope-merge fix.
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
"---\nwing: jepa-fx\nhall: facts\ncreated_at: 2026-01-01T00:00:00Z\ncustom: keep-me\n---\n# Old\n\nold body\n")
|
||||||
|
|
||||||
|
relPath, hash, priorHash, err := UpdateNote(brainDir, UpdateNoteOptions{
|
||||||
|
Path: rel,
|
||||||
|
Content: "# New\n\nnew body\n",
|
||||||
|
Reason: "facts changed",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, rel, relPath)
|
||||||
|
assert.NotEmpty(t, hash)
|
||||||
|
assert.NotEqual(t, hash, priorHash)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
assert.Contains(t, s, "# New")
|
||||||
|
assert.Contains(t, s, "custom: keep-me")
|
||||||
|
assert.Contains(t, s, "supersedes: "+priorHash)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
func TestUpdateNoteResolvesByWingHallSlug(t *testing.T) {
|
||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md",
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ type FileBuffer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type bufferLine struct {
|
type bufferLine struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Entry capture.AuditEntry `json:"entry"`
|
Entry capture.AuditEntry `json:"entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
// NewFileBuffer returns a buffer backed by path. The parent directory is
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
|
|||||||
// unwritableBuffer always reports it cannot be written (floor condition).
|
// unwritableBuffer always reports it cannot be written (floor condition).
|
||||||
type unwritableBuffer struct{}
|
type unwritableBuffer struct{}
|
||||||
|
|
||||||
func (unwritableBuffer) Writable() error { return errors.New("disk full") }
|
func (unwritableBuffer) Writable() error { return errors.New("disk full") }
|
||||||
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
|
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
|
||||||
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
|
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
|
||||||
func (unwritableBuffer) Confirm(string) error { return nil }
|
func (unwritableBuffer) Confirm(string) error { return nil }
|
||||||
|
|
||||||
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
func newFileBuffer(t *testing.T) *audit.FileBuffer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -54,11 +54,24 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md"))
|
||||||
needle := strings.ToLower(strings.TrimSpace(title))
|
title = strings.TrimSpace(title)
|
||||||
if needle == "" {
|
if title == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
idx := strings.Index(lowerContent, needle)
|
|
||||||
|
// A one-word title has to match the prose case-exactly. Matched
|
||||||
|
// case-insensitively, it links every entry that happens to use the
|
||||||
|
// word: "WRONG" (wiki/telos/decisions/wrong.md) collected 185 inbound
|
||||||
|
// See-also links and "Status" 136, making the two most-linked nodes in
|
||||||
|
// the graph artifacts of the English language rather than references.
|
||||||
|
// Multi-word titles keep the case-insensitive match — "risk tier
|
||||||
|
// middleware" in prose is a real mention however it is capitalised,
|
||||||
|
// and a three-word collision is not an accident.
|
||||||
|
haystack, needle := lowerContent, strings.ToLower(title)
|
||||||
|
if isSingleWord(title) {
|
||||||
|
haystack, needle = content, title
|
||||||
|
}
|
||||||
|
idx := strings.Index(haystack, needle)
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,7 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
out = append(out, TunnelCandidate{
|
out = append(out, TunnelCandidate{
|
||||||
TargetPath: filepath.ToSlash(rel),
|
TargetPath: filepath.ToSlash(rel),
|
||||||
MatchedTerm: title,
|
MatchedTerm: title,
|
||||||
Exact: isWholeWord(lowerContent, idx, len(needle)),
|
Exact: isWholeWord(haystack, idx, len(needle)),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -79,6 +92,11 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSingleWord reports whether title is one whitespace-free token.
|
||||||
|
func isSingleWord(title string) bool {
|
||||||
|
return !strings.ContainsAny(title, " \t")
|
||||||
|
}
|
||||||
|
|
||||||
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
// isWholeWord reports whether the substring at [idx, idx+n) in s is
|
||||||
// bounded by non-alphanumeric characters (or string edges).
|
// bounded by non-alphanumeric characters (or string edges).
|
||||||
func isWholeWord(s string, idx, n int) bool {
|
func isWholeWord(s string, idx, n int) bool {
|
||||||
|
|||||||
@@ -102,16 +102,29 @@ func TestDetectTunnels_ExactTitleMatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
func TestDetectTunnels_FuzzyMatch(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
seedNote(t, dir, "wiki/x/facts/routing.md",
|
seedNote(t, dir, "wiki/x/facts/routing-table.md",
|
||||||
"---\ntitle: Routing\n---\nbody.\n")
|
"---\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||||
|
|
||||||
// Substring of title appears in content, but not as a whole word.
|
// Substring of title appears in content, but not as a whole word.
|
||||||
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
candidates, err := brain.DetectTunnels(dir, "the routing tables were rebuilt")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, candidates, 1)
|
require.Len(t, candidates, 1)
|
||||||
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
assert.False(t, candidates[0].Exact, "substring-only match should be fuzzy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-word title whose only appearance is lowercase inside another word is
|
||||||
|
// not even a candidate: it would fill the review file with English rather than
|
||||||
|
// with references.
|
||||||
|
func TestDetectTunnels_SingleWordSubstringIsNotACandidate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/x/facts/routing.md",
|
||||||
|
"---\ntitle: Routing\n---\nbody.\n")
|
||||||
|
|
||||||
|
candidates, err := brain.DetectTunnels(dir, "rerouting handles failover")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
seedNote(t, dir, "wiki/x/facts/widget-flags.md", "# widget flags\n\nbody.\n")
|
||||||
@@ -126,20 +139,21 @@ func TestDetectTunnels_NoFrontmatterFallsBackToSlug(t *testing.T) {
|
|||||||
|
|
||||||
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
// Existing note in a different wing whose title is "Routing".
|
// Existing note in a different wing whose title is "Routing Table".
|
||||||
seedNote(t, dir, "wiki/other/facts/routing.md",
|
seedNote(t, dir, "wiki/other/facts/routing-table.md",
|
||||||
"---\nwing: other\nhall: facts\ntitle: Routing\n---\nbody.\n")
|
"---\nwing: other\nhall: facts\ntitle: 'Routing Table'\n---\nbody.\n")
|
||||||
// Source note in another wing whose body mentions "rerouting" (substring match only).
|
// Source note in another wing whose body mentions "routing tables"
|
||||||
|
// (substring match only — the title is not there as a whole word).
|
||||||
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
seedNote(t, dir, "wiki/jepa-fx/facts/new.md",
|
||||||
"---\nwing: jepa-fx\nhall: facts\n---\nrerouting traffic\n")
|
"---\nwing: jepa-fx\nhall: facts\n---\nrouting tables rebuilt\n")
|
||||||
|
|
||||||
require.NoError(t, brain.AutoTunnel(dir,
|
require.NoError(t, brain.AutoTunnel(dir,
|
||||||
"wiki/jepa-fx/facts/new.md", "rerouting traffic"))
|
"wiki/jepa-fx/facts/new.md", "routing tables rebuilt"))
|
||||||
|
|
||||||
// Source must not get auto-linked (fuzzy).
|
// Source must not get auto-linked (fuzzy).
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/jepa-fx/facts/new.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, string(got), "[[other/facts/routing]]")
|
assert.NotContains(t, string(got), "[[other/facts/routing-table]]")
|
||||||
|
|
||||||
// Candidates file must list the pair.
|
// Candidates file must list the pair.
|
||||||
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
matches, err := filepath.Glob(filepath.Join(dir, "raw", "tunnel-candidates-*.md"))
|
||||||
@@ -148,7 +162,7 @@ func TestAutoTunnel_FuzzyGoesToCandidatesFile(t *testing.T) {
|
|||||||
body, err := os.ReadFile(matches[0])
|
body, err := os.ReadFile(matches[0])
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
assert.Contains(t, string(body), "wiki/jepa-fx/facts/new.md")
|
||||||
assert.Contains(t, string(body), "wiki/other/facts/routing.md")
|
assert.Contains(t, string(body), "wiki/other/facts/routing-table.md")
|
||||||
assert.Contains(t, string(body), "Routing")
|
assert.Contains(t, string(body), "Routing")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +189,35 @@ func TestWriteTunnel_AppendsToExistingSeeAlso(t *testing.T) {
|
|||||||
assert.Contains(t, s, "[[a/facts/old]]")
|
assert.Contains(t, s, "[[a/facts/old]]")
|
||||||
assert.Contains(t, s, "[[b/facts/y]]")
|
assert.Contains(t, s, "[[b/facts/y]]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-word title matched case-insensitively links every entry that happens
|
||||||
|
// to use that word. Live cost before this rule: 185 entries auto-linked to
|
||||||
|
// wiki/telos/decisions/wrong.md (title "WRONG") and 136 to .../status.md,
|
||||||
|
// making the two most-linked nodes in the graph pure artifacts.
|
||||||
|
func TestDetectTunnels_SingleWordTitleNeedsAnExactCaseMatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/telos/decisions/wrong.md",
|
||||||
|
"---\nwing: telos\nhall: decisions\ntitle: WRONG\n---\n# WRONG\n\nRetired beliefs.\n")
|
||||||
|
|
||||||
|
got, err := brain.DetectTunnels(dir, "The obvious fix was to reject the write. Wrong here.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, c := range got {
|
||||||
|
assert.False(t, c.Exact, "a lowercase prose word must not auto-link a one-word title: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = brain.DetectTunnels(dir, "The beliefs listed under WRONG were retired.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.True(t, got[0].Exact, "the title's own casing appearing in prose is a real reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectTunnels_MultiWordTitleStaysCaseInsensitive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
seedNote(t, dir, "wiki/homelab/facts/risk-tier.md",
|
||||||
|
"---\nwing: homelab\nhall: facts\ntitle: 'Risk Tier Middleware'\n---\nbody\n")
|
||||||
|
|
||||||
|
got, err := brain.DetectTunnels(dir, "We rebuilt the risk tier middleware last week.")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.True(t, got[0].Exact)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]]")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -233,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 {
|
||||||
@@ -253,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 {
|
||||||
@@ -297,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 {
|
||||||
|
|||||||
@@ -134,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package mcp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -180,6 +181,13 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R
|
|||||||
}
|
}
|
||||||
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, notes.ErrDegenerate) && a.OldStr == "" {
|
||||||
|
// hyperguild#115: "old_str is empty" alone doesn't say whether the
|
||||||
|
// caller sent it empty or a hop between the caller and here lost
|
||||||
|
// it. Naming the raw argument byte count makes that distinguishable
|
||||||
|
// without a second investigation.
|
||||||
|
return notes.Result{}, nil, fmt.Errorf("%w (raw arguments were %d bytes)", err, len(args))
|
||||||
|
}
|
||||||
return notes.Result{}, nil, err
|
return notes.Result{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ func TestBrainReadAndEdit_RoundTripThroughJSONRPC(t *testing.T) {
|
|||||||
"only the matched bytes may change")
|
"only the matched bytes may change")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrainEdit_EmptyOldStrErrorNamesRawArgumentBytes(t *testing.T) {
|
||||||
|
// hyperguild#115: a session reported "old_str is empty" with an old_str
|
||||||
|
// that, on their end, was never empty. The server-side path was
|
||||||
|
// unreachable to reproduce (verified separately: a fresh brain_edit call
|
||||||
|
// with real content round-trips cleanly), which points at something
|
||||||
|
// between a specific client and this call losing the field. The fix this
|
||||||
|
// issue actually asked for isn't a behavior change — old_str=="" must
|
||||||
|
// still refuse — it's that the refusal should say how many raw argument
|
||||||
|
// bytes it received, so "client sent empty" is distinguishable from
|
||||||
|
// "server received empty after some hop lost it" without a second
|
||||||
|
// session's worth of guessing.
|
||||||
|
dir := newBrainRepo(t)
|
||||||
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := call(t, srv, "brain_edit", map[string]any{
|
||||||
|
"path": "wiki/homelab/facts/n.md", "old_str": "", "new_str": "BETA",
|
||||||
|
})
|
||||||
|
errMsg, ok := resp["error"].(map[string]any)["message"].(string)
|
||||||
|
require.True(t, ok, "%v", resp)
|
||||||
|
assert.Contains(t, errMsg, "old_str is empty")
|
||||||
|
assert.Contains(t, errMsg, "raw arguments were", "%v", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
func TestBrainEdit_RefusesGovernanceFilesFromTheModelFacingTool(t *testing.T) {
|
||||||
dir := newBrainRepo(t)
|
dir := newBrainRepo(t)
|
||||||
srv := mcp.NewServer(dir, nil, nil, nil)
|
srv := mcp.NewServer(dir, nil, nil, nil)
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
||||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||||
|
if rp.SourceHash != "" {
|
||||||
|
fmt.Fprintf(&sb, "source_hash: %s\n", rp.SourceHash)
|
||||||
|
}
|
||||||
if rp.Source != "" {
|
if rp.Source != "" {
|
||||||
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ type RawPage struct {
|
|||||||
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
||||||
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
||||||
// might emit. Only meaningful for Type == "source".
|
// might emit. Only meaningful for Type == "source".
|
||||||
Source string `json:"-"`
|
// SourceHash is sha256 of the ingested BODY (frontmatter excluded), stamped
|
||||||
Author string `json:"-"`
|
// so the next ingest of the same source can tell "unchanged" from "edited"
|
||||||
Published string `json:"-"`
|
// without re-running extraction (#105).
|
||||||
|
SourceHash string `json:"-"`
|
||||||
|
Source string `json:"-"`
|
||||||
|
Author string `json:"-"`
|
||||||
|
Published string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
||||||
@@ -112,6 +116,11 @@ type sourceMeta struct {
|
|||||||
Source string
|
Source string
|
||||||
Author string
|
Author string
|
||||||
Published string
|
Published string
|
||||||
|
// BodyHash is sha256 of the content with any leading frontmatter removed.
|
||||||
|
// Body-only on purpose: a re-crawl that stamps a new fetch date has not
|
||||||
|
// changed the source, and treating that as drift would re-extract the
|
||||||
|
// whole corpus every time a crawler ran (#105).
|
||||||
|
BodyHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseContentFrontmatter extracts source/author/published from a leading
|
// parseContentFrontmatter extracts source/author/published from a leading
|
||||||
@@ -154,6 +163,7 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) {
|
|||||||
if pages[i].Type != "source" {
|
if pages[i].Type != "source" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
pages[i].SourceHash = meta.BodyHash
|
||||||
pages[i].Source = meta.Source
|
pages[i].Source = meta.Source
|
||||||
pages[i].Author = meta.Author
|
pages[i].Author = meta.Author
|
||||||
pages[i].Published = meta.Published
|
pages[i].Published = meta.Published
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -26,6 +28,11 @@ type Config struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Pages []string // relative paths written (or would-be written in dry-run)
|
Pages []string // relative paths written (or would-be written in dry-run)
|
||||||
Warnings []string
|
Warnings []string
|
||||||
|
// Skipped reports that the source was byte-identical to the last ingest,
|
||||||
|
// so no extraction ran and nothing was written. Reported rather than
|
||||||
|
// silent: a run that does nothing must say so, or "the pipeline is fine"
|
||||||
|
// and "the pipeline stopped working" look the same (#105, #107).
|
||||||
|
Skipped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
||||||
@@ -43,6 +50,18 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
|
|
||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
||||||
|
// pass over every chunk to produce a result that merges to nothing (#105).
|
||||||
|
// The merge is already idempotent (#103), so this is cost and signal, not
|
||||||
|
// correctness — which is why it is safe for it to be an early return.
|
||||||
|
bodyHash := hashBody(content)
|
||||||
|
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) {
|
||||||
|
return Result{Skipped: true, Warnings: []string{
|
||||||
|
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
chunks := Chunk(content, cfg.ChunkSize)
|
chunks := Chunk(content, cfg.ChunkSize)
|
||||||
|
|
||||||
var allRaw []RawPage
|
var allRaw []RawPage
|
||||||
@@ -59,7 +78,9 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
allWarnings = append(allWarnings, warnings...)
|
allWarnings = append(allWarnings, warnings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
applySourceMeta(allRaw, parseContentFrontmatter(content))
|
meta := parseContentFrontmatter(content)
|
||||||
|
meta.BodyHash = bodyHash
|
||||||
|
applySourceMeta(allRaw, meta)
|
||||||
|
|
||||||
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
}
|
}
|
||||||
@@ -96,7 +117,19 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
|||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
|
content, conflicts, err := reviseOrCreate(dest, page)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
for _, heading := range conflicts {
|
||||||
|
// Reported, not resolved. The on-disk version stands; whether
|
||||||
|
// the disagreement is a genuine contradiction is the
|
||||||
|
// Judge-gated enrich pass's call, not the write path's.
|
||||||
|
warnings = append(warnings, fmt.Sprintf(
|
||||||
|
"%s: source now disagrees on section %q — on-disk version kept",
|
||||||
|
page.Path, heading))
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +148,76 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
|||||||
return Result{Pages: written, Warnings: warnings}, nil
|
return Result{Pages: written, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashBody returns sha256 of content with any leading frontmatter block
|
||||||
|
// removed, or "" for empty content.
|
||||||
|
//
|
||||||
|
// Body-only on purpose: a re-crawl that stamps a new fetch date has not changed
|
||||||
|
// the source, and treating that as drift would re-extract the corpus every time
|
||||||
|
// a crawler ran (#105).
|
||||||
|
func hashBody(content string) string {
|
||||||
|
body := content
|
||||||
|
if strings.HasPrefix(body, "---\n") {
|
||||||
|
if _, rest, found := strings.Cut(body[4:], "\n---"); found {
|
||||||
|
body = strings.TrimPrefix(rest, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedSourceHash reads source_hash from the source page a previous ingest
|
||||||
|
// wrote, or "" when there is no such page or no such field.
|
||||||
|
//
|
||||||
|
// Absence always means "extract". That is the fail-safe direction: a missed
|
||||||
|
// skip costs one extraction, while a wrong skip loses an update silently — and
|
||||||
|
// silence is the failure mode this repo keeps paying for.
|
||||||
|
func storedSourceHash(brainDir, sourceSlug string) string {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviseOrCreate returns the bytes to write for one extracted page: merged into
|
||||||
|
// whatever is already on disk, or the page as-is when the path is new.
|
||||||
|
//
|
||||||
|
// This is the compile half of compile-on-ingest. Before it, the write was an
|
||||||
|
// unconditional os.WriteFile, so re-ingesting a source replaced its page
|
||||||
|
// wholesale and discarded everything accumulated since — including human edits
|
||||||
|
// (hyperguild#43: "re-ingest overwrites wholesale").
|
||||||
|
//
|
||||||
|
// The merge is deliberately asymmetric in the existing page's favour:
|
||||||
|
// wiki.Merge keeps the on-disk frontmatter and the on-disk version of any
|
||||||
|
// section both sides carry, adds sections only the extraction produced, and
|
||||||
|
// unions bullet lists. So a freshly extracted page can add to a note but never
|
||||||
|
// silently rewrite what a human put there — which is what makes it safe to run
|
||||||
|
// this on every ingest rather than behind a review gate.
|
||||||
|
//
|
||||||
|
// Known limitation: because the on-disk frontmatter wins, `last_updated` keeps
|
||||||
|
// its first-ingest date on a revised page. Left deliberately — the alternative
|
||||||
|
// is this function editing frontmatter it does not own, and no lint reads that
|
||||||
|
// field today.
|
||||||
|
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
|
||||||
|
existing, err := os.ReadFile(dest)
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
return page.Content, nil, nil
|
||||||
|
case err != nil:
|
||||||
|
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
|
||||||
|
}
|
||||||
|
merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page)
|
||||||
|
return merged.Content, conflicts, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
||||||
func mergeAll(pages []wiki.Page) []wiki.Page {
|
func mergeAll(pages []wiki.Page) []wiki.Page {
|
||||||
order := make([]string, 0, len(pages))
|
order := make([]string, 0, len(pages))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -171,3 +172,156 @@ func mustJSON(v any) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- B1: compile-on-ingest — revise the existing page, never clobber it -----
|
||||||
|
|
||||||
|
func TestRunRaw_MergesIntoAnExistingPageInsteadOfOverwritingIt(t *testing.T) {
|
||||||
|
// Before this, buildAndWrite did an unconditional os.WriteFile: re-ingesting
|
||||||
|
// a source replaced its page wholesale, discarding anything accumulated
|
||||||
|
// since — including human edits. hyperguild#43 recorded the symptom as
|
||||||
|
// "re-ingest overwrites wholesale".
|
||||||
|
dir := t.TempDir()
|
||||||
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(`---
|
||||||
|
title: 'Shape Up'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A book about shaping product work.
|
||||||
|
|
||||||
|
## My Take
|
||||||
|
|
||||||
|
Read this before the next planning cycle.
|
||||||
|
`), 0o644))
|
||||||
|
|
||||||
|
_, err := RunRaw(dir, "shape-up", []RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
||||||
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
||||||
|
}}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(page)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(got)
|
||||||
|
|
||||||
|
assert.Contains(t, s, "## My Take", "a section the extraction never saw must survive the merge")
|
||||||
|
assert.Contains(t, s, "Read this before the next planning cycle.")
|
||||||
|
assert.Contains(t, s, "Appetite beats estimation.", "newly extracted material must still land")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
|
||||||
|
// Compile-on-ingest runs on every ingest, so "nothing changed" must produce
|
||||||
|
// no diff at all. Otherwise every re-ingest dirties the tree, brain-sync
|
||||||
|
// commits it, and the git history fills with noise that hides real edits.
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw := []RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
||||||
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := RunRaw(dir, "shape-up", raw, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = RunRaw(dir, "shape-up", raw, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, string(first), string(second),
|
||||||
|
"re-ingesting unchanged content must leave the page byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- #105: source drift — an unchanged re-ingest must not re-extract --------
|
||||||
|
|
||||||
|
func TestRun_SkipsExtractionWhenTheSourceIsUnchanged(t *testing.T) {
|
||||||
|
// #103 made a re-ingest harmless (byte-identical merge). It is still not
|
||||||
|
// free: the full LLM extraction runs over every chunk to produce a result
|
||||||
|
// that merges to nothing. This is the cost half.
|
||||||
|
dir := t.TempDir()
|
||||||
|
var calls int
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
calls++
|
||||||
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
content := "---\nsource: https://example.com/shape-up\n---\n\nThe body of the article.\n"
|
||||||
|
|
||||||
|
first, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, calls, "first ingest must extract")
|
||||||
|
assert.False(t, first.Skipped)
|
||||||
|
|
||||||
|
second, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, calls, "unchanged content must not be extracted again")
|
||||||
|
assert.True(t, second.Skipped, "the skip must be reported, not silent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ReExtractsWhenTheSourceBodyChanged(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
var calls int
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
calls++
|
||||||
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion one\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion two — the source was edited\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, calls, "changed content must be re-extracted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
|
||||||
|
// The hash covers the body, so re-fetching a page whose metadata moved
|
||||||
|
// (a re-crawl stamping a new date) is not treated as the source changing.
|
||||||
|
dir := t.TempDir()
|
||||||
|
var calls int
|
||||||
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
||||||
|
calls++
|
||||||
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: monday\n---\n\nsame body\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: tuesday\n---\n\nsame body\n", "shape-up", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, calls, "only the body counts as the source")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRaw_ReportsWhenTheSourceDisagreesWithWhatIsOnDisk(t *testing.T) {
|
||||||
|
// The on-disk version still wins — that is what makes unattended ingest
|
||||||
|
// safe. But the run must say that the source now claims something else,
|
||||||
|
// or a changed source is indistinguishable from an unchanged one.
|
||||||
|
dir := t.TempDir()
|
||||||
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(`---
|
||||||
|
title: 'Shape Up'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is fixed, scope is variable.
|
||||||
|
`), 0o644))
|
||||||
|
|
||||||
|
res, err := RunRaw(dir, "shape-up", []RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product",
|
||||||
|
Content: "## Summary\n\nAppetite is negotiable and scope is fixed.\n",
|
||||||
|
}}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(page)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Appetite is fixed", "on-disk version wins")
|
||||||
|
|
||||||
|
joined := strings.Join(res.Warnings, " ")
|
||||||
|
assert.Contains(t, joined, "Summary", "the disagreeing section must be named")
|
||||||
|
assert.Contains(t, joined, "shape-up", "and the page it happened on")
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ type Entry struct {
|
|||||||
type Attempt struct {
|
type Attempt struct {
|
||||||
Attempt int `json:"attempt"`
|
Attempt int `json:"attempt"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Tier string `json:"tier"` // local | subagent | managed
|
Tier string `json:"tier"` // local | subagent | managed
|
||||||
DurationMs int64 `json:"duration_ms"`
|
DurationMs int64 `json:"duration_ms"`
|
||||||
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
|
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
|
||||||
Verified bool `json:"verified"`
|
Verified bool `json:"verified"`
|
||||||
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
||||||
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
||||||
OutputSummary string `json:"output_summary,omitempty"`
|
OutputSummary string `json:"output_summary,omitempty"`
|
||||||
RunnerOutput string `json:"runner_output,omitempty"`
|
RunnerOutput string `json:"runner_output,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
|
|||||||
calls++
|
calls++
|
||||||
return "ready", nil
|
return "ready", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Do: %v", err)
|
t.Fatalf("Do: %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,7 +40,6 @@ func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return "ready", nil
|
return "ready", nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
|
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import (
|
|||||||
// reachable database.
|
// reachable database.
|
||||||
//
|
//
|
||||||
// To run:
|
// To run:
|
||||||
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
//
|
||||||
// go test ./internal/vectorstore/... -run Integration
|
// BRAIN_PG_TEST_DSN='postgres://brain_app:pwd@127.0.0.1:5432/brain' \
|
||||||
|
// go test ./internal/vectorstore/... -run Integration
|
||||||
func dsn(t *testing.T) string {
|
func dsn(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
v := os.Getenv("BRAIN_PG_TEST_DSN")
|
v := os.Getenv("BRAIN_PG_TEST_DSN")
|
||||||
|
|||||||
@@ -29,13 +29,40 @@ type section struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge combines two Page values with the same path.
|
// Merge combines two Page values with the same path.
|
||||||
// Frontmatter is taken from a. Sections are merged by strategy:
|
// Frontmatter is taken from a. The preamble (content before the first ##
|
||||||
// bullet sections union unique lines, append sections concatenate,
|
// heading) is taken from a — same "on-disk wins" default every unlisted
|
||||||
// all others keep a's version. Sections in b not present in a are appended.
|
// named section gets, kept as a separate field rather than a section with
|
||||||
|
// an empty heading so it can never collide with one (see parseSections).
|
||||||
|
// Sections are merged by strategy: bullet sections union unique lines,
|
||||||
|
// append sections concatenate, all others keep a's version. Sections in b
|
||||||
|
// not present in a are appended.
|
||||||
|
// Merge discards any disagreement silently; prefer MergeReport where the
|
||||||
|
// caller can act on it.
|
||||||
func Merge(a, b Page) Page {
|
func Merge(a, b Page) Page {
|
||||||
fmA, secsA := parseSections(a.Content)
|
page, _ := MergeReport(a, b)
|
||||||
_, secsB := parseSections(b.Content)
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeReport is Merge, plus the headings of sections where b carried a
|
||||||
|
// different version of a section a already had — and lost.
|
||||||
|
//
|
||||||
|
// That loss is the property which makes ingest safe to run unattended (#103):
|
||||||
|
// a regenerating writer can add to a note but never rewrite it. But the
|
||||||
|
// discarded text is also a signal, and until now it went in the bin: the source
|
||||||
|
// now says something different about a section we already hold. Nobody could
|
||||||
|
// tell "the source is unchanged" from "the source changed its mind and we
|
||||||
|
// ignored it".
|
||||||
|
//
|
||||||
|
// Deliberately NOT a semantic contradiction check. This reports textual
|
||||||
|
// disagreement, which is cheap, deterministic, and needs no model on the write
|
||||||
|
// path. Deciding whether a disagreement is a genuine contradiction stays with
|
||||||
|
// the Judge-gated enrich pass that already exists (brain-gardener#175), which
|
||||||
|
// is approval-gated by design — detection here, adjudication there.
|
||||||
|
func MergeReport(a, b Page) (Page, []string) {
|
||||||
|
fmA, preambleA, secsA := parseSections(a.Content)
|
||||||
|
fmB, _, secsB := parseSections(b.Content)
|
||||||
|
|
||||||
|
var conflicts []string
|
||||||
idx := make(map[string]int, len(secsA))
|
idx := make(map[string]int, len(secsA))
|
||||||
for i, s := range secsA {
|
for i, s := range secsA {
|
||||||
idx[s.heading] = i
|
idx[s.heading] = i
|
||||||
@@ -53,14 +80,78 @@ func Merge(a, b Page) Page {
|
|||||||
case bulletSections[sB.heading]:
|
case bulletSections[sB.heading]:
|
||||||
secsA[i].content = mergeBullets(sA.content, sB.content)
|
secsA[i].content = mergeBullets(sA.content, sB.content)
|
||||||
case appendSections[sB.heading]:
|
case appendSections[sB.heading]:
|
||||||
secsA[i].content = strings.TrimRight(sA.content, "\n") + "\n\n" + strings.TrimLeft(sB.content, "\n")
|
secsA[i].content = appendNew(sA.content, sB.content)
|
||||||
|
default:
|
||||||
|
// a wins — but say that b disagreed, rather than dropping it mute.
|
||||||
|
if strings.TrimSpace(sA.content) != strings.TrimSpace(sB.content) {
|
||||||
|
conflicts = append(conflicts, sB.heading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)}
|
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}, conflicts
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSections(markdown string) (frontmatter string, sections []section) {
|
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
|
||||||
|
// on a page it is revising. Everything else on disk wins.
|
||||||
|
//
|
||||||
|
// #103 made the on-disk frontmatter win wholesale, which is what stops a
|
||||||
|
// regenerating writer from overwriting human-set metadata — a title someone
|
||||||
|
// fixed, or the claim-property envelope (brain#21). The cost was that fields
|
||||||
|
// the generator legitimately owns froze at their first-ingest values: when it
|
||||||
|
// last ran, and what it last saw.
|
||||||
|
//
|
||||||
|
// An explicit allowlist rather than a carve-out: the ownership boundary is
|
||||||
|
// nameable, and a field nobody has thought about stays human-owned by default,
|
||||||
|
// which is the safe direction.
|
||||||
|
var generatorOwned = map[string]bool{
|
||||||
|
"last_updated": true,
|
||||||
|
"source_hash": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshOwned returns a's frontmatter with the generator-owned fields taken
|
||||||
|
// from b where b supplies them. Fields absent from b keep a's value, so a
|
||||||
|
// generator that stops emitting a field cannot blank it.
|
||||||
|
func refreshOwned(a, b string) string {
|
||||||
|
bVals := make(map[string]string)
|
||||||
|
for _, line := range strings.Split(b, "\n") {
|
||||||
|
key, value, found := strings.Cut(line, ":")
|
||||||
|
if !found || !generatorOwned[strings.TrimSpace(key)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bVals[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if len(bVals) == 0 {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(strings.Split(a, "\n")))
|
||||||
|
for _, line := range strings.Split(a, "\n") {
|
||||||
|
key, _, found := strings.Cut(line, ":")
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
if found && generatorOwned[k] {
|
||||||
|
if v, ok := bVals[k]; ok {
|
||||||
|
out = append(out, k+": "+v)
|
||||||
|
delete(bVals, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSections splits markdown into its frontmatter, its preamble (content
|
||||||
|
// before the first `## ` heading), and its `## `-headed sections.
|
||||||
|
//
|
||||||
|
// The preamble is returned separately rather than as a section with an
|
||||||
|
// empty heading — a section keyed by heading=="" would collide with a
|
||||||
|
// literal degenerate `## ` (marker, no title text; strings.TrimPrefix also
|
||||||
|
// yields ""), which is a distinct, addressable section a page can genuinely
|
||||||
|
// have, not the same thing as "no heading yet." Two entries sharing one key
|
||||||
|
// in Merge's idx map means one of them silently loses lookup visibility —
|
||||||
|
// found in review as real, reproducible data loss, not a hypothetical.
|
||||||
|
func parseSections(markdown string) (frontmatter, preamble string, sections []section) {
|
||||||
lines := strings.Split(markdown, "\n")
|
lines := strings.Split(markdown, "\n")
|
||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
@@ -78,6 +169,22 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
|
|||||||
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
|
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything before the first `## ` heading — an intro paragraph, or, if
|
||||||
|
// the page has no headings at all, the entire body — is ordinary content,
|
||||||
|
// not something this parser is entitled to drop (hyperguild#103 review:
|
||||||
|
// dropping it meant Merge, one caller up, would silently replace the
|
||||||
|
// whole on-disk body with the fresh extraction whenever a page didn't
|
||||||
|
// follow the ## convention — exactly the class of bug this file exists
|
||||||
|
// to prevent).
|
||||||
|
var preambleLines []string
|
||||||
|
for ; i < len(lines); i++ {
|
||||||
|
if strings.HasPrefix(lines[i], "## ") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
preambleLines = append(preambleLines, lines[i])
|
||||||
|
}
|
||||||
|
preamble = strings.Join(preambleLines, "\n")
|
||||||
|
|
||||||
var cur *section
|
var cur *section
|
||||||
for ; i < len(lines); i++ {
|
for ; i < len(lines); i++ {
|
||||||
line := lines[i]
|
line := lines[i]
|
||||||
@@ -96,24 +203,85 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func rebuildContent(frontmatter string, sections []section) string {
|
// rebuildContent serialises sections back into a page.
|
||||||
|
//
|
||||||
|
// Each section body is normalised to exactly one trailing newline and no
|
||||||
|
// leading blank line. Without that, a section keeps whatever blank lines it was
|
||||||
|
// parsed with and this function adds its own, so every merge pass grows the page
|
||||||
|
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
|
||||||
|
// merges on every run, so "merging changes nothing when nothing changed" has to
|
||||||
|
// hold at the byte level, not just the content level.
|
||||||
|
func rebuildContent(frontmatter, preamble string, sections []section) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(frontmatter)
|
sb.WriteString(frontmatter)
|
||||||
|
if body := strings.Trim(preamble, "\n"); body != "" {
|
||||||
|
fmt.Fprintf(&sb, "\n%s\n", body)
|
||||||
|
}
|
||||||
for _, sec := range sections {
|
for _, sec := range sections {
|
||||||
fmt.Fprintf(&sb, "\n## %s\n\n%s", sec.heading, sec.content)
|
body := strings.Trim(sec.content, "\n")
|
||||||
|
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canonicalLine collapses a line to its comparison key: internal whitespace
|
||||||
|
// runs (a re-extraction's bullet spacing, tabs vs spaces, trailing spaces)
|
||||||
|
// must not make the same logical line look new. strings.Fields already
|
||||||
|
// trims and splits on any whitespace run, so joining it back with single
|
||||||
|
// spaces is exactly "same words, same order, whitespace-insensitive."
|
||||||
|
func canonicalLine(line string) string {
|
||||||
|
return strings.Join(strings.Fields(line), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendNew concatenates the lines of b that a does not already carry.
|
||||||
|
//
|
||||||
|
// Append sections used to concatenate wholesale, which is correct exactly once.
|
||||||
|
// Compile-on-ingest merges a freshly extracted page into the one already on
|
||||||
|
// disk, so an unchanged source re-ingested would grow "Open Questions" and
|
||||||
|
// "Updates" on every run until the page is unreadable — and the drift guard
|
||||||
|
// that would otherwise catch it (hashing the source body) does not exist yet,
|
||||||
|
// so idempotency has to hold here rather than upstream.
|
||||||
|
//
|
||||||
|
// Line-level rather than block-level: the common case is a re-ingest carrying
|
||||||
|
// one genuinely new line alongside several it already has. A block-level
|
||||||
|
// "skip if identical" would duplicate the old lines every time that happened.
|
||||||
|
//
|
||||||
|
// The cost is that a line repeated verbatim on purpose collapses to one. For
|
||||||
|
// these sections — questions, updates, evolving notes — a repeat is noise
|
||||||
|
// rather than signal, and the failure is conservative: the page stops growing
|
||||||
|
// instead of growing wrongly.
|
||||||
|
func appendNew(a, b string) string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, line := range strings.Split(a, "\n") {
|
||||||
|
if c := canonicalLine(line); c != "" {
|
||||||
|
seen[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var add []string
|
||||||
|
for _, line := range strings.Split(b, "\n") {
|
||||||
|
c := canonicalLine(line)
|
||||||
|
if c == "" || seen[c] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c] = true
|
||||||
|
add = append(add, line)
|
||||||
|
}
|
||||||
|
if len(add) == 0 {
|
||||||
|
return a // nothing new — leave the section byte-identical
|
||||||
|
}
|
||||||
|
return strings.TrimRight(a, "\n") + "\n\n" + strings.Join(add, "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
func mergeBullets(a, b string) string {
|
func mergeBullets(a, b string) string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var lines []string
|
var lines []string
|
||||||
for _, line := range strings.Split(a+b, "\n") {
|
for _, line := range strings.Split(a+b, "\n") {
|
||||||
trimmed := strings.TrimSpace(line)
|
c := canonicalLine(line)
|
||||||
if trimmed == "" || seen[trimmed] {
|
if c == "" || seen[c] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[trimmed] = true
|
seen[c] = true
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n") + "\n"
|
return strings.Join(lines, "\n") + "\n"
|
||||||
|
|||||||
@@ -53,3 +53,217 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
|
|||||||
assert.Contains(t, got.Content, "title: A")
|
assert.Contains(t, got.Content, "title: A")
|
||||||
assert.NotContains(t, got.Content, "title: B")
|
assert.NotContains(t, got.Content, "title: B")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- hyperguild#103 review: a page with content outside any `## ` section ---
|
||||||
|
// must never lose that content on merge. parseSections used to only capture
|
||||||
|
// text between `## ` headings — anything before the first heading (a common,
|
||||||
|
// ordinary shape for a hand-written note: an intro paragraph before the first
|
||||||
|
// section) or, worse, an entire page with no `## ` headings at all (a legacy
|
||||||
|
// note, a stub, any hand-edit that doesn't follow the section convention)
|
||||||
|
// vanished silently. Merge would then fill the page entirely from the fresh
|
||||||
|
// extraction — the opposite of "never silently rewrite what a human put
|
||||||
|
// there," the guarantee this whole file exists to provide.
|
||||||
|
|
||||||
|
func TestMerge_PreservesOnDiskContentWithNoSectionHeadings(t *testing.T) {
|
||||||
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nJust some free text a human wrote. No section headings at all.\n"}
|
||||||
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nFresh extraction's take.\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Contains(t, got.Content, "Just some free text a human wrote",
|
||||||
|
"on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble(t *testing.T) {
|
||||||
|
// Second review pass on #103: representing the preamble as a section with
|
||||||
|
// heading=="" collides with a literal `## ` (marker, no title text) —
|
||||||
|
// strings.TrimPrefix("## ", "## ") is also "". `a` has only a preamble
|
||||||
|
// (no degenerate heading of its own); `b` introduces a genuinely new
|
||||||
|
// degenerate `## ` section. Because both key to "", Merge's idx thinks
|
||||||
|
// b's section "already exists" in a (it's really just a's unrelated
|
||||||
|
// preamble) and never adds it — b's section vanishes from the output
|
||||||
|
// entirely, not merged, not kept, not appended.
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph.\n\n## Definition\n\nA thing.\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## \n\nA genuinely new degenerate section's body.\n\n## Definition\n\nA thing.\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Contains(t, got.Content, "An intro paragraph.", "a's own preamble must survive")
|
||||||
|
assert.Contains(t, got.Content, "A genuinely new degenerate section's body.",
|
||||||
|
"b's degenerate section is new content, not a collision with a's unrelated preamble — it must not be silently dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) {
|
||||||
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"}
|
||||||
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Contains(t, got.Content, "An intro paragraph before the first heading",
|
||||||
|
"text before the first ## heading is ordinary page content, not something the parser is entitled to drop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hyperguild#103 review: dedup must be whitespace-insensitive -----------
|
||||||
|
// Page content is produced by an LLM per run, so byte-identical re-extraction
|
||||||
|
// of "the same" logical line is not guaranteed — different bullet spacing or
|
||||||
|
// trailing whitespace on an otherwise-identical line must still dedup, or
|
||||||
|
// idempotency only holds when the extractor happens to reproduce whitespace
|
||||||
|
// exactly, which is not a real guarantee.
|
||||||
|
|
||||||
|
func TestMerge_AppendSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge?\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge? \n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Equal(t, 1, strings.Count(got.Content, "Does this converge?"),
|
||||||
|
"same logical line with different whitespace must dedup, not duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_BulletSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
||||||
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
||||||
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
||||||
|
|
||||||
|
got := Merge(a, b)
|
||||||
|
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"),
|
||||||
|
"same logical bullet with different whitespace must dedup, not duplicate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- brain#21/B1: merging must be safe to run again -------------------------
|
||||||
|
|
||||||
|
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
|
||||||
|
// Compile-on-ingest merges a freshly extracted page into the one already
|
||||||
|
// on disk. Bullet sections union, so they are naturally idempotent — but
|
||||||
|
// append sections concatenate, so re-ingesting an unchanged source would
|
||||||
|
// grow "Open Questions" on every run until the page is unreadable.
|
||||||
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: X
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does this converge?
|
||||||
|
`}
|
||||||
|
|
||||||
|
once := Merge(page, page)
|
||||||
|
twice := Merge(once, page)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(once.Content, "Does this converge?"),
|
||||||
|
"merging a page with itself must not duplicate an append section")
|
||||||
|
assert.Equal(t, once.Content, twice.Content,
|
||||||
|
"merge must be idempotent — a third pass may not keep growing the page")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_AppendSectionStillAccumulatesGenuinelyNewContent(t *testing.T) {
|
||||||
|
// The dedup must not turn append sections into keep-a-only sections:
|
||||||
|
// new material is exactly what compile-on-ingest exists to add.
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: X
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does this converge?
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: X
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What happens under load?
|
||||||
|
`}
|
||||||
|
|
||||||
|
got := Merge(existing, fresh).Content
|
||||||
|
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
||||||
|
assert.Contains(t, got, "What happens under load?", "new question must be added")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshed(t *testing.T) {
|
||||||
|
// #103 made on-disk frontmatter win wholesale, which is what protects
|
||||||
|
// human-set fields from a regenerating writer. The cost was that fields the
|
||||||
|
// generator legitimately owns — when it last ran, what it last saw — froze
|
||||||
|
// at their first-ingest values. An explicit owned-set is the boundary:
|
||||||
|
// named fields the generator may refresh, everything else human-owned.
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
last_updated: 2026-01-01
|
||||||
|
source_hash: aaaa
|
||||||
|
half_life: durable
|
||||||
|
claim_provenance: asserted
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Original.
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'Renamed by the model'
|
||||||
|
last_updated: 2026-08-22
|
||||||
|
source_hash: bbbb
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Rewritten by the model.
|
||||||
|
`}
|
||||||
|
|
||||||
|
got := Merge(existing, fresh).Content
|
||||||
|
|
||||||
|
assert.Contains(t, got, "last_updated: 2026-08-22", "generator owns last_updated")
|
||||||
|
assert.Contains(t, got, "source_hash: bbbb", "generator owns source_hash")
|
||||||
|
assert.Contains(t, got, "title: 'X'", "a human-set title must survive regeneration")
|
||||||
|
assert.Contains(t, got, "half_life: durable", "the claim envelope must survive regeneration")
|
||||||
|
assert.Contains(t, got, "claim_provenance: asserted")
|
||||||
|
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
|
||||||
|
assert.NotContains(t, got, "Renamed by the model")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_ReportsSectionsWhereTheFreshVersionWasDiscarded(t *testing.T) {
|
||||||
|
// The asymmetry that makes ingest safe (on-disk wins) also throws away a
|
||||||
|
// real signal: the source now says something different about a section we
|
||||||
|
// already have. Today that disagreement is silently discarded, so nobody
|
||||||
|
// can tell "the source is unchanged" from "the source changed its mind and
|
||||||
|
// we ignored it".
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is fixed, scope is variable.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Six-week cycles.
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is negotiable and scope is fixed.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Six-week cycles.
|
||||||
|
`}
|
||||||
|
|
||||||
|
got, conflicts := MergeReport(existing, fresh)
|
||||||
|
|
||||||
|
assert.Contains(t, got.Content, "Appetite is fixed", "on-disk version still wins")
|
||||||
|
assert.NotContains(t, got.Content, "negotiable")
|
||||||
|
assert.Equal(t, []string{"Summary"}, conflicts,
|
||||||
|
"only the section that actually disagreed is reported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_ReportsNothingWhenTheFreshVersionAgrees(t *testing.T) {
|
||||||
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is fixed, scope is variable.
|
||||||
|
`}
|
||||||
|
|
||||||
|
_, conflicts := MergeReport(page, page)
|
||||||
|
assert.Empty(t, conflicts, "identical content is not a disagreement")
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -89,10 +89,10 @@ func TestValidator(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
token string
|
token string
|
||||||
wantSub string
|
wantSub string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid jwt",
|
name: "valid jwt",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ type Config struct {
|
|||||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default http://iguana:4000
|
LiteLLMBaseURL string // LITELLM_BASE_URL, default http://iguana:4000
|
||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
ConfigDir string // SUPERVISOR_CONFIG_DIR, default ./config/supervisor
|
||||||
ModelsFile string // SUPERVISOR_MODELS_FILE, default <ConfigDir>/../models.yaml
|
|
||||||
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
|
IngestBaseURL string // INGEST_BASE_URL, default http://localhost:3300
|
||||||
IngestSvcURL string // INGEST_SVC_URL — base URL for brain_ingest (/ingest, /ingest-path)
|
IngestSvcURL string // INGEST_SVC_URL — base URL for brain_ingest (/ingest, /ingest-path)
|
||||||
KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search
|
KBRetrievalURL string // KB_RETRIEVAL_URL — base URL for brain_search
|
||||||
@@ -23,7 +22,6 @@ func Load() (Config, error) {
|
|||||||
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
LiteLLMAPIKey: os.Getenv("LITELLM_API_KEY"),
|
||||||
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
ConfigDir: envOr("SUPERVISOR_CONFIG_DIR", "./config/supervisor"),
|
||||||
}
|
}
|
||||||
cfg.ModelsFile = envOr("SUPERVISOR_MODELS_FILE", cfg.ConfigDir+"/../models.yaml")
|
|
||||||
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
|
cfg.IngestBaseURL = envOr("INGEST_BASE_URL", "http://localhost:3300")
|
||||||
cfg.IngestSvcURL = envOr("INGEST_SVC_URL", "")
|
cfg.IngestSvcURL = envOr("INGEST_SVC_URL", "")
|
||||||
cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "")
|
cfg.KBRetrievalURL = envOr("KB_RETRIEVAL_URL", "")
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type skillChain struct {
|
|
||||||
Chain []string `yaml:"chain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type modelsFile struct {
|
|
||||||
DefaultChain []string `yaml:"default_chain"`
|
|
||||||
Skills map[string]skillChain `yaml:"skills"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Models struct {
|
|
||||||
data modelsFile
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadModels(path string) (Models, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return Models{}, fmt.Errorf("load models: %w", err)
|
|
||||||
}
|
|
||||||
var f modelsFile
|
|
||||||
if err := yaml.Unmarshal(raw, &f); err != nil {
|
|
||||||
return Models{}, fmt.Errorf("parse models: %w", err)
|
|
||||||
}
|
|
||||||
return Models{data: f}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelFor returns the primary model to use for a skill.
|
|
||||||
// If override is non-empty, it is returned directly.
|
|
||||||
// Falls back to default_chain[0] when the skill has no explicit entry.
|
|
||||||
func (m Models) ModelFor(skill, override string) string {
|
|
||||||
if override != "" {
|
|
||||||
return override
|
|
||||||
}
|
|
||||||
if sc, ok := m.data.Skills[skill]; ok && len(sc.Chain) > 0 {
|
|
||||||
return sc.Chain[0]
|
|
||||||
}
|
|
||||||
if len(m.data.DefaultChain) > 0 {
|
|
||||||
return m.data.DefaultChain[0]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.d-ma.be/mathias/hyperguild/internal/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testYAML = `
|
|
||||||
default_chain:
|
|
||||||
- ollama/qwen3-coder-30b-tuned
|
|
||||||
- claude-sonnet-4-6
|
|
||||||
|
|
||||||
skills:
|
|
||||||
review:
|
|
||||||
chain:
|
|
||||||
- ollama/devstral-tuned
|
|
||||||
- ollama/gemma4
|
|
||||||
- claude-sonnet-4-6
|
|
||||||
spec:
|
|
||||||
chain:
|
|
||||||
- ollama/phi4
|
|
||||||
- claude-opus-4-6
|
|
||||||
`
|
|
||||||
|
|
||||||
func writeModels(t *testing.T, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
f := filepath.Join(t.TempDir(), "models.yaml")
|
|
||||||
require.NoError(t, os.WriteFile(f, []byte(content), 0644))
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelsModelForSkillWithEntry(t *testing.T) {
|
|
||||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "ollama/devstral-tuned", m.ModelFor("review", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelsModelForDefaultFallback(t *testing.T) {
|
|
||||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "ollama/qwen3-coder-30b-tuned", m.ModelFor("trainer", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelsModelForCallerOverride(t *testing.T) {
|
|
||||||
m, err := config.LoadModels(writeModels(t, testYAML))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "claude-opus-4-6", m.ModelFor("review", "claude-opus-4-6"))
|
|
||||||
}
|
|
||||||
@@ -9,14 +9,14 @@ import (
|
|||||||
// RoutingConfig holds the runtime configuration for the routing pod.
|
// RoutingConfig holds the runtime configuration for the routing pod.
|
||||||
// Separate from Config because the routing pod's surface differs from the supervisor's.
|
// Separate from Config because the routing pod's surface differs from the supervisor's.
|
||||||
type RoutingConfig struct {
|
type RoutingConfig struct {
|
||||||
Port string // ROUTING_PORT, default 3210
|
Port string // ROUTING_PORT, default 3210
|
||||||
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
|
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
|
||||||
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
|
||||||
LiteLLMAPIKey string // LITELLM_API_KEY
|
LiteLLMAPIKey string // LITELLM_API_KEY
|
||||||
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
|
||||||
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
|
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
|
||||||
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
|
||||||
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
|
||||||
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual
|
||||||
// floor < ceil mathematical convention: Floor (default 0.90) is the
|
// floor < ceil mathematical convention: Floor (default 0.90) is the
|
||||||
// UPPER boundary — at/above it, always route local; Ceil (default 0.70)
|
// UPPER boundary — at/above it, always route local; Ceil (default 0.70)
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
|
|
||||||
// Client calls an MCP server over Streamable HTTP / JSON-RPC.
|
// Client calls an MCP server over Streamable HTTP / JSON-RPC.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
url string
|
url string
|
||||||
token string
|
token string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrTokenRequired is returned by New when token is empty. Empty token
|
// ErrTokenRequired is returned by New when token is empty. Empty token
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ func TestFormatHistoryFormatsEntries(t *testing.T) {
|
|||||||
entries := []session.Entry{
|
entries := []session.Entry{
|
||||||
{
|
{
|
||||||
Skill: "tdd", Phase: "red", FinalStatus: "pass",
|
Skill: "tdd", Phase: "red", FinalStatus: "pass",
|
||||||
FilePath: "internal/foo/foo_test.go",
|
FilePath: "internal/foo/foo_test.go",
|
||||||
Message: "wrote failing test for Foo",
|
Message: "wrote failing test for Foo",
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ type Entry struct {
|
|||||||
type Attempt struct {
|
type Attempt struct {
|
||||||
Attempt int `json:"attempt"`
|
Attempt int `json:"attempt"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Tier string `json:"tier"` // local | subagent | managed
|
Tier string `json:"tier"` // local | subagent | managed
|
||||||
DurationMs int64 `json:"duration_ms"`
|
DurationMs int64 `json:"duration_ms"`
|
||||||
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
|
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
|
||||||
Verified bool `json:"verified"`
|
Verified bool `json:"verified"`
|
||||||
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
||||||
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
||||||
OutputSummary string `json:"output_summary,omitempty"`
|
OutputSummary string `json:"output_summary,omitempty"`
|
||||||
RunnerOutput string `json:"runner_output,omitempty"`
|
RunnerOutput string `json:"runner_output,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Boot the routing binary and exercise its four tools against live deps.
|
|
||||||
# Skipped when LITELLM_BASE_URL or BRAIN_URL is unreachable.
|
|
||||||
|
|
||||||
LITELLM_BASE_URL="${LITELLM_BASE_URL:-https://llm-api.d-ma.be}"
|
|
||||||
BRAIN_URL="${BRAIN_URL:-http://koala:30330}"
|
|
||||||
|
|
||||||
if ! curl -sS --max-time 2 "${LITELLM_BASE_URL}/v1/models" >/dev/null 2>&1; then
|
|
||||||
echo "SKIP: LITELLM at ${LITELLM_BASE_URL} unreachable"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if ! curl -sS --max-time 2 "${BRAIN_URL}/query" -X POST -d '{"query":"x","k":1}' -H 'Content-Type: application/json' >/dev/null 2>&1; then
|
|
||||||
echo "SKIP: BRAIN at ${BRAIN_URL} unreachable"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT=33310
|
|
||||||
BIN=$(mktemp)
|
|
||||||
trap 'rm -f $BIN; pkill -P $$ -f "$BIN" 2>/dev/null || true' EXIT
|
|
||||||
|
|
||||||
go build -o "$BIN" ./cmd/routing
|
|
||||||
|
|
||||||
LITELLM_BASE_URL="$LITELLM_BASE_URL" BRAIN_URL="$BRAIN_URL" \
|
|
||||||
ROUTING_PORT="$PORT" SUPERVISOR_CONFIG_DIR="$(pwd)/config/supervisor" \
|
|
||||||
"$BIN" &
|
|
||||||
|
|
||||||
# Wait for the binary to bind.
|
|
||||||
for _ in $(seq 1 50); do
|
|
||||||
curl -sS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 && break
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
call_tool() {
|
|
||||||
local tool="$1"
|
|
||||||
local args="$2"
|
|
||||||
curl -sS -X POST "http://127.0.0.1:${PORT}/mcp" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"${tool}\",\"arguments\":${args}}}" \
|
|
||||||
| jq -e '.result // .error' > /dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "calling tools/list..."
|
|
||||||
curl -sS -X POST "http://127.0.0.1:${PORT}/mcp" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
|
|
||||||
| jq -r '.result.tools | map(.name) | sort | .[]'
|
|
||||||
|
|
||||||
echo "calling each tool..."
|
|
||||||
call_tool review '{"project_root":"/tmp","files":["README.md"],"session_id":"smoke-1"}'
|
|
||||||
call_tool debug '{"project_root":"/tmp","error":"smoke test","session_id":"smoke-1"}'
|
|
||||||
call_tool retrospective '{"session_id":"smoke-1"}'
|
|
||||||
call_tool trainer '{"session_id":"smoke-1"}'
|
|
||||||
|
|
||||||
echo "checking brain has _routing entries..."
|
|
||||||
sleep 2
|
|
||||||
COUNT=$(curl -sS "${BRAIN_URL}/pass-rate?skill=_routing&window=1h" | jq -r '.total // 0')
|
|
||||||
if [ "${COUNT}" -lt 4 ]; then
|
|
||||||
echo "FAIL: expected >=4 _routing entries in last 1h, got ${COUNT}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "PASS: smoke:routing"
|
|
||||||
@@ -49,12 +49,13 @@ Persist the session via a **single `capture` call** (the `brain:capture` MCP too
|
|||||||
**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.
|
||||||
|
Optional claim envelope, per insight: `half_life` ∈ volatile/seasonal/durable, `applies` ∈ local/homelab/universal, `claim_provenance` ∈ asserted/inferred — required together whenever either `half_life` or `applies` is set (an envelope with no provenance is rejected). A close-session insight is usually exactly the durable, generalizable, human-decided kind this describes: `half_life: durable, applies: universal, claim_provenance: asserted` is the common case, not a rare opt-in.
|
||||||
- **`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).
|
||||||
- **`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`), 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.
|
- **The rule, not a list**: untagged → confidential (fail-safe). `classification.yaml` at the brain root is the sole authority on what's tagged `internal` today — it grew from 10 to ~39 repos in one sweep (hyperguild#91) and will keep moving, so read the file rather than trust an enumeration frozen at whatever moment this skill was last edited.
|
||||||
- Declare `context.classification: "internal"` for normal homelab work.
|
- Declare `context.classification: "internal"` for normal homelab work.
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user