Compare commits

..
Author SHA1 Message Date
mathiasandClaude Sonnet 5 43f43e4621 fix(wiki): parseFrontmatterBlock must not treat comments or indented lines as keys
CI / Lint / Test / Vet (pull_request) Successful in 40s
CI / Mirror to GitHub (pull_request) Skipped
Third review round on PR #126. Two real bugs in the rewrite from the
previous commit:

1. The comment guard the sibling api package's frontmatter parser has
   (strings.HasPrefix(trimmed, "#")) was dropped in this reimplementation.
   A frontmatter comment containing a colon got cut at the colon and
   re-rendered with collapsed internal whitespace.
2. Worse: an indented YAML block-list item containing a colon (e.g. a
   title with a subtitle inside an aliases: list) was misparsed as a
   top-level key/value pair -- strings.Cut doesn't know a line is a
   continuation just because TrimSpace(key) happens to be non-empty.
   Re-rendering then stripped the line's indentation, de-indenting it
   out of the list it belonged to. refreshOwned re-renders this block on
   essentially every non-skipped ingest, so this was the normal path,
   not an edge case.

Checked whether the sibling api/frontmatter.go has the same list-item gap:
it does (same strings.Cut-then-TrimSpace shape, no indentation check) --
not a regression introduced here, a pre-existing latent defect shared
with the package this was modeled on. Filed separately rather than fixed
here (out of scope for this PR, needs its own review).

Fixed by checking indentation BEFORE calling Cut at all -- a line
starting with a space or tab is a continuation line, full stop,
regardless of what it contains -- and restoring the comment guard
verbatim from the sibling.

Flip-tested both guards independently: removing either one turns its
corresponding new test red in isolation; the other stays green,
confirming they're independent fixes, not one masking the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:07:21 +02:00
mathiasandClaude Sonnet 5 874206be9d fix(wiki): rewrite refreshOwned on a parsed line list, not fence-index tracking
CI / Lint / Test / Vet (pull_request) Successful in 33s
CI / Mirror to GitHub (pull_request) Skipped
Second review round on PR #126 found two more problems with the first
append fix:

1. When a has no frontmatter fence at all, the fallback spliced raw,
   unfenced "key: value" text straight into the body -- worse than the
   original bug, since the next parseSections call wouldn't recognize
   it as frontmatter and would absorb it as preamble text, duplicating
   on every subsequent merge.
2. The regression test only asserted substrings (Contains), which
   doesn't prove structural position. Two independent mutations
   (closeIdx locking onto the wrong fence, a reversed sort) both
   survived the full suite undetected.

Rewritten from string-index surgery (tracking a fence's position across
two passes over split lines) to a small parsed representation: fmLine
(key/value or raw), parseFrontmatterBlock, setLine (update in place or
append -- the one behavior the original code got wrong), and
renderFrontmatterBlock. Same shape as the sibling api package's own
frontmatter type, reproduced locally rather than imported (it's
unexported there, and this package's needs are smaller) because that
set()-appends-if-absent behavior was already proven correct by that
package's existing test suite -- this bug class shouldn't have existed
here in the first place.

A page with no fence gets one created to hold the new fields, instead
of corrupting the body. Tests now assert exact frontmatter block output
via assert.Equal, not Contains, and a new fixture (mixed replace+append
in one call) covers both code paths together.

Flip-tested: reversing the sort turns two of the new exact-match tests
red (the Contains-based version of the same test missed this on the
prior commit). The "wrong fence" bug class is now structurally
impossible rather than merely untested -- there's no fence-position
index left to get wrong.

Also fixed a byte-stability regression the new render introduced and
caught via the full pipeline suite: a key with no inline value (the
YAML block-list shape, "aliases:" followed by indented "- item" lines)
rendered as "aliases: " (trailing space) instead of "aliases:".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:01:27 +02:00
mathiasandClaude Sonnet 5 41a9112860 fix(wiki): append generator-owned frontmatter fields a page never had, not just replace
CI / Lint / Test / Vet (pull_request) Successful in 38s
CI / Mirror to GitHub (pull_request) Skipped
Review of PR #126 found refreshOwned only replaced a generator-owned line
that already existed in a's frontmatter -- it never appended one a didn't
have. Every page ingested before this feature shipped has no source_hash
line at all, so on its first re-ingest after deploy, the fresh hash was
silently dropped: storedSourceHash reads "" forever for that page, the
hash never matches, and the skip-on-unchanged feature this whole PR/issue
exists to build never engages for the entire pre-existing corpus,
permanently, with nothing telling anyone.

Fixed by tracking the closing frontmatter fence's position while
scanning a's lines, then inserting any generator-owned fields b supplied
that a didn't have just before that fence -- sorted for a deterministic,
byte-stable result, since map iteration order isn't. Flip-tested:
forcing the old drop-silently return turns the new test red; restoring
the fix turns it green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 06:56:41 +02:00
mathias a5a328fea5 feat(pipeline): skip extraction when the source has not changed (#105)
CI / Lint / Test / Vet (pull_request) Successful in 34s
CI / Mirror to GitHub (pull_request) Skipped
Ingest now stamps source_hash — sha256 of the ingested body, frontmatter
excluded — onto the source page, and compares it before doing any work. An
unchanged source returns Skipped with no LLM call at all.

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

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

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

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

Refs #105, #103, #107, brain#21.
2026-09-22 23:45:25 +02:00
mathias f450a6c84f feat(pipeline): ingest revises the existing page instead of clobbering it (#103)
CI / Lint / Test / Vet (push) Successful in 32s
CI / Mirror to GitHub (push) Successful in 5s
fix(wiki): give the preamble its own field instead of overloading heading=="" (#103)

Round 3 review: verified structurally that the preamble is no longer in the sections slice or the idx map, so it cannot collide with a degenerate `## ` heading. Flip-tested by reverting to the heading=="" model and confirming TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble goes red. Adversarial pass (multiple degenerate headings, frontmatter-only page, unicode around the ## boundary, 3x self-merge idempotency with preamble+degenerate) all held. CI green on 09aeaa9 (run 7820).

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-22 21:43:04 +00:00
mathias 5a5d938e8e Brain-quality sweep, batch 1: brain_edit/brain_update correctness + close-session docs (#124)
CI / Lint / Test / Vet (push) Successful in 32s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-22 21:27:11 +00:00
mathiasandClaude Opus 5 cc86a74dc5 fix(ci): stop the tag push from re-deploying, and make the infra bump idempotent
CI / Lint / Test / Vet (push) Successful in 34s
CI / Mirror to GitHub (push) Successful in 4s
Two runs of the full build+deploy fired for every push, and the second died:

    nothing to commit, working tree clean
    ❌  Failure - Main Update infra repo

The deploy was fine both times — the second run had nothing left to do. The
cause is the pair of triggers meeting: ci.yml ran on `push` to main AND on a
`v*` tag, and cd.yml keys off "CI succeeded", so `git push origin main` followed
by `git push origin refs/tags/vX` produced two CI runs for one sha and two
deploys behind them. The tag run carries the same github.sha, so it rebuilt the
same image and then found the infra manifest already pointing at it.

Nothing consumed the tag-triggered run: there is no release workflow, and cd.yml
reads github.sha rather than the tag. Dropping `tags:` removes the duplicate
build outright.

The idempotency guard stays anyway, because a manual re-run has the same shape
and should not ring the CI-red alarm for work that is already done — infra#366
turns a red run into a tracked issue, so a false red costs a real triage.

Closes #118

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 07:09:14 +02:00
mathiasandClaude Opus 5 6e449b464c fix(taskfile): remove build targets for binaries deleted months ago, and make check build (#121)
CI / Lint / Test / Vet (push) Successful in 32s
CI / Mirror to GitHub (push) Successful in 4s
Removes supervisor:dev, supervisor:build, supervisor:test:smoke, smoke:routing and scripts/smoke-routing.sh -- all pointed at cmd/supervisor (deleted c117634, 2026-05-12) or cmd/routing (iceboxed fb59c39, 2026-07-01). Repoints `build` at hyperguild:build + ingestion:build and adds `build` to `check`, so a task target naming a deleted package fails the gate instead of staying invisible to lint/test/vet.

Refs #120. Item 2 of #120 (internal/config has no non-test callers) is deliberately NOT addressed -- #120 stays open for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-16 22:36:49 +00:00
mathias 868fe28c96 fix(config): delete the model-routing chain — nothing has called it since April (#119)
CI / Lint / Test / Vet (push) Successful in 31s
CI / Mirror to GitHub (push) Successful in 4s
Closes #116.

Deletes config/models.yaml, internal/config/models.go (LoadModels, ModelFor),
its test, the ModelsFile field on Config, and SUPERVISOR_MODELS_FILE from
.env.example. Option A from the issue.

Merge-gate evidence: negative claim independently re-established (no Go, shell,
Dockerfile, k8s, CI or infra-repo caller); task check exit 0 across both
modules; CI run 7374 green on the PR head.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-09-16 22:24:47 +00:00
mathiasandClaude Opus 5 6e69698343 fix(brain): a one-word tunnel title must match the prose case-exactly
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 31s
AutoTunnel matched note titles case-insensitively, so a one-word title linked
every entry that happened to use that word. Measured on the live corpus: 185
entries carry an auto-written See-also link to wiki/telos/decisions/wrong.md
(title "WRONG") and 136 to .../status.md — the two most-linked nodes in the
whole graph, and both of them artifacts of the English language rather than
references. telos's projects/problems/strategies/goals/beliefs entries account
for another ~60 between them.

That pollution is not cosmetic: inbound-link count is what the duplicate
Fixer picks survivors by, what orphan detection reads, and what brain_graph
shows.

One-word titles now need the title's own casing in the prose ("WRONG", "BERT",
"NLP" — an entity name in a sentence still links). Multi-word titles keep the
case-insensitive match: "risk tier middleware" is a real mention however it is
capitalised, and a three-word collision is not an accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:08:40 +02:00
mathiasandClaude Opus 5 eb2acfeeb8 fix(brain): move the wikilink strip into WriteNote/UpdateNote
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 32s
The BrainStore is not the only door. POST /write — the HTTP path CLAUDE.md
hands to curl and scripts — calls api.WriteNote directly, so the strip
shipped one commit ago covered brain_write and capture while leaving the raw
door ungated. A gate one door wide is not a gate.

The BrainStore keeps its own call so it can still report Ref.Unlinked to the
MCP caller; running the strip twice is a no-op on content the primitive has
already cleaned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:59:44 +02:00
mathiasandClaude Opus 5 6e5a9cfce3 fix(brain): unwrap wikilinks that name no entry at the write boundary
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 35s
Every brain write accepted [[targets]] that resolve to nothing — skill
names, repo names, agent-local memory-file slugs — and each one became a
permanent [high] broken-link finding. The brain-audit digest grew 19 -> 63
high findings between 2026-08-26 and 2026-09-16; 53 of the 63 were exactly
this, and none are fixable by brain-gardener's BrokenLinkFixer, which only
repoints edit-distance-1 renames. There is no target to repoint to.

StripUnresolvedLinks resolves each link against knowledge/ + wiki/ using
brain-gardener's own resolver semantics (bare slug or wing/hall/slug path
form, code spans excluded) and unwraps the misses to plain text, reporting
them as Ref.Unlinked / the MCP `unlinked` field.

Unwrap rather than reject: a refused write on claudewatcher's unattended
ingest path is a lost learning, and the author's words are worth keeping
even when the link is not. An empty corpus is a no-op, so a misconfigured
brainDir cannot strip every link in a note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:53:42 +02:00
mathiasandClaude Opus 5 25b79f057e feat(claudewatcher): exclude whole project directories from ingestion
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 1m20s
CLAUDE_INGEST_SKIP_PROJECTS takes filepath.Match globs tested against
the project directory NAME, and a matching directory is skipped during
the directory WALK — so its files are never opened and their content
never enters the process. That is a stronger property than filtering
after reading, and the difference is observable: without SkipDir,
WalkDir lists the directory's entries, and an unreadable one aborts the
entire walk, silently stopping ingestion for every other project.

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 no exact-match list could keep up with.

An unparseable pattern refuses to run. filepath.Match returns an error
ALONGSIDE a false match, so a bad pattern checked per-file would ingest
the directory while the log complained — the failure nobody notices.
Validated once, up front, in both entry points.

This complements the client-name scrubber rather than replacing it. The
scrubber drops individual turns whose content matches a client
identity; this excludes whole workspaces whose content is confidential
in ways no pattern reliably recognises. Learnings from excluded
sessions are captured deliberately through the session-close ritual
instead.

Recorded caveat: the project directory reflects the session's working
directory, NOT the repositories it touches. A session run from ~/dev
that reads an excluded repo is not excluded by this. The scrubber
remains the control for content; this is a control for workspaces.

A mutation test earned the strongest assertion here: the original tests
could not distinguish SkipDir from filtering after the walk, because
neither opens files. The unreadable-directory test can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:09:58 +02:00
mathias 47bbac4b9f revert(lint): back out the canonical .golangci.yml adoption — repo has a nested module
CI / Lint / Test / Vet (push) Successful in 30s
CI / Mirror to GitHub (push) Successful in 3s
Adopting it turned CI red, and my pre-push verification missed why:
`golangci-lint run ./...` does NOT traverse into nested Go modules. This repo
has two — ./go.mod and ./ingestion/go.mod. I linted the root (0 issues, which
CI reproduces exactly) and never linted ingestion/, which has 3 findings:

  ingestion/internal/audit/degrading.go:81   missing case capture.AuditCentral   (exhaustive)
  ingestion/internal/capture/entities.go:37  missing case capture.ZoneUnknown    (exhaustive)
  ingestion/internal/mcp/handlers.go:29      var int_ should be int              (revive)

Those are behavioural questions and a rename, not formatting — the same class
of finding that kept cad, dispatch, ingestion-svc, dma-cli and
email-triage-agent out of the earlier waves. Consistent with that, this repo
goes back in the queue rather than being force-fitted now.

The gofumpt formatting from the same commit is kept: it is independently
correct and already green.

Verification method corrected going forward: a repo with nested modules must
be linted per module, not once from the root.
2026-09-05 14:47:39 +02:00
mathias 8a27ead91c chore(lint): adopt the canonical .golangci.yml from infra/ci-templates
CI / Mirror to GitHub (push) Skipped
CI / Lint / Test / Vet (push) Failing after 12s
Canonical lives at infra ci-templates/golangci.yml, distributed by the
infra#309 pattern: byte-identical copies, checksum drift gate in infra. Do
not edit this file here -- edit the canonical and run `task ci:sync`.

Verified locally before pushing, not pushed-and-hoped: golangci-lint v2.11.4
against a Go 1.26.1 toolchain (matching CI) reports 0 issues here.

Also applies gofumpt, which this config enables and the golangci-lint v2
default set does not check at all. That gap is why the drift was invisible:
earlier passes ran gofmt, which is weaker. Mechanical, deterministic,
build+vet verified.
2026-09-05 14:42:59 +02:00
mathias 1a9be0cfb1 style: apply gofmt
CI / Lint / Test / Vet (push) Successful in 30s
CI / Mirror to GitHub (push) Successful in 4s
Estate-wide consolidation pass. golangci-lint v2 default set enables NO
formatter, so gofmt drift accumulated silently across the estate -- 8 repos
were carrying it, this one included. Same hole that let two files rot in
dma-cli until they were found by hand (dma-cli#12).

Mechanical and deterministic: `gofmt -w`, nothing hand-edited. Verified
before commit -- gofmt -l now reports 0, go build and go vet both pass.

Clears this repo to adopt the canonical .golangci.yml (infra
ci-templates/golangci.yml, infra#309 pattern) in a later wave.
2026-09-05 12:40:34 +02:00
mathias dc2265da0b fix(ci): skip context:sync's drift check under CI, matching dma-cli/gitea-mcp
CI / Mirror to GitHub (push) Successful in 4s
CI / Lint / Test / Vet (push) Successful in 1m22s
hyperguild's Taskfile ran context:sync unconditionally in 'check', with no
guard for the CI runner having no ancestor .context/AGENT.md. That made it
silently regenerate AGENTS.md and .context/system-prompt.txt from project
context alone (82 lines vs. the committed, correct 430) and then fail its
own drift assertion against that wrong baseline every run -- a false
positive, not real drift. Verified locally: syncing with the real root
context reproduces the committed 430-line files byte for byte; syncing
without it (as CI does) produces the shorter fallback that triggered this.

Same guard dma-cli and gitea-mcp already carry, same rationale: drift is
now caught either on a developer machine or by infra's estate-wide
context-sync-drift CronJob (ADR-0019), which does have canonical access.
2026-09-05 11:36:45 +02:00
mathias c176b71b3d fix(context): AGENTS.md/system-prompt.txt committed in 4e8bdc1 were missing root content
CI / Lint / Test / Vet (push) Failing after 2s
CI / Mirror to GitHub (push) Skipped
The previous commit's regeneration produced AGENTS.md byte-identical to
CLAUDE.md (82 lines, project-only) instead of the expected root+project
concatenation (430 lines) -- root_block() rendered empty despite the run's
own log claiming "Root context: .../AGENT.md" and "AGENTS.md (root +
project...)". Not reproduced: re-running the identical command
(ROOT_CONTEXT=.../AGENT.md bash scripts/context-sync.sh) immediately after
produces the correct 430-line output every time. Whatever caused the
original miss was transient and is not diagnosed here -- flagging rather
than asserting a cause I have not confirmed.

Caught by the estate-wide context-sync-drift gate's own verification run,
not by trusting the fix commit. Regenerated fresh and confirmed root
content is present before committing this time.
2026-09-05 08:36:03 +02:00
mathias 4e8bdc137e fix(context): sync to the estate canonical context-sync.sh (ADR-0019)
CI / Lint / Test / Vet (push) Successful in 1m5s
CI / Mirror to GitHub (push) Successful in 4s
Caught by the new estate-wide context-sync-drift gate (infra, ADR-0019).
This repo's copy had diverged from the canonical established in dma-cli/
infra -- synced to it, dead Cursor/Aider artifacts removed (2026-09-01
harness decision), Taskfile's drift assertion narrowed to match, adapters
regenerated against the current canonical AGENT.md.

Changed:
M  .context/system-prompt.txt
M  AGENTS.md
M  scripts/context-sync.sh

Refs: ADR-0019 (infra)
2026-09-05 08:30:04 +02:00
mathias 42235dcb6a fix(scan): an allowlist was hiding a real Gitea PAT on a false justification
CI / Lint / Test / Vet (push) Successful in 32s
CI / Mirror to GitHub (push) Successful in 5s
infra#344. The removed block suppressed
docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md, and its description
stated why:

    "Literal placeholder word REDACTED matched as if it were a token
     (verified 2026-08-04: extracted Secret == 'REDACTED' exactly, gitleaks'
      curl-auth-header rule matched the placeholder text itself, not a real
      credential)"

That is not what the file contained. Line 1054 carried a real 40-character hex
Gitea PAT in an `Authorization: token ...` header — not the eight-character word
REDACTED. Whether the file changed after the check or the check was wrong, the
justification has been guarding the wrong thing since.

Confirmed dead by side-effect before touching it, never by printing it: `GET
/api/v1/user` with that token returns 401. So there is nothing to rotate. It is
redacted here anyway, because a dead credential in a doc is still a working
example of how to write a live one, and git history keeps the original either
way.

Two independent failures had to line up, which is the same pair as infra#256:

1. The `paths` clause made the block far broader than its description. gitleaks
   ORs global allowlist clauses and ignores `condition = "AND"`, so the ENTIRE
   file was immune — a live token pasted into that doc tomorrow would have been
   suppressed on the strength of a note about a different value.
2. The justification was never re-checked against the file it justifies.

With the token redacted the allowlist has nothing left to do, so it is deleted
rather than narrowed. Repo scans clean, and a planted live-shaped credential in
that same file is now caught.

Refs: mathias/infra#344, mathias/infra#256, mathias/infra#317
2026-08-31 07:24:07 +02:00
mathiasandClaude Sonnet 5 05330ad54a feat(brain): add Gemini as brain_answer's third LLM tier
CI / Lint / Test / Vet (push) Successful in 36s
CI / Mirror to GitHub (push) Successful in 4s
llm.Router was primary-then-fallback (berget -> iguana ollama). Both
have failed together at least once in production (berget 5xx outage +
a cold fallback model exceeding the shared timeout budget), leaving
brain_answer/brain_classify fully down.

Router gains an optional Tertiary *Client, tried only if both Primary
and Fallback fail. Nil-safe — existing 2-tier deployments (no
BRAIN_LLM_TERTIARY_URL) behave exactly as before.

main.go wires it from BRAIN_LLM_TERTIARY_URL/MODEL + GEMINI_API_KEY,
pointed at Google's OpenAI-compatible Gemini endpoint
(generativelanguage.googleapis.com/v1beta/openai/) — llm.Client already
speaks OpenAI-shaped chat-completions, so no new client type needed.

Tests: TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail,
TestRouter_TertiaryNotCalledWhenFallbackSucceeds, TestRouter_AllThreeFail,
TestRouter_NilTertiary (2-tier backward-compat). Full suite green
(both modules, -race), golangci-lint clean.

Deployment env vars (infra repo, separate commit — GitOps, TBD there
too): BRAIN_LLM_TERTIARY_URL, BRAIN_LLM_TERTIARY_MODEL, GEMINI_API_KEY
secretRef.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 14:43:25 +02:00
mathias 692244d213 security: disposition the one historical gitleaks finding (infra#326)
CI / Lint / Test / Vet (push) Successful in 31s
CI / Mirror to GitHub (push) Successful in 3s
A Gitea PAT was pasted into a CD-watching curl command in a 2026-04-22
planning doc (c9310b10, last_eight 3105a670).

Verified dead before dispositioning it, rather than assuming: authenticating
GET /api/v1/user with that exact value returns 401. It was almost certainly
invalidated by the early-August rotation of the `mathias` PATs -- the same
rotation whose fan-out caused infra#260, where CI's stale copies kept
failing. So it was killed for unrelated reasons, by luck rather than by the
leak being noticed.

No rotation outstanding. `task security:gitleaks:history` now exits 0 here.

Used .gitleaksignore (per-finding fingerprint) rather than a path allowlist
in .gitleaks.toml: a path allowlist would also blind the working-tree scan
that gates commits. Verified both directions -- history 1 -> 0, working tree
still clean.
2026-08-29 10:04:11 +02:00
mathias d98a305985 security(hooks): add local pre-commit gitleaks scan (infra#326)
CI / Lint / Test / Vet (push) Successful in 1m33s
CI / Mirror to GitHub (push) Successful in 4s
task check -> security:gitleaks was already CI-enforced here, but that
catches a leak after the push, not before -- exactly the gap infra#39's
prevention checklist called out ("gitleaks pre-commit hook in both infra
and hyperguild"), true for infra but not this repo.

Mirrors infra's own .githooks/pre-commit, minus the YAML/image-tag
checks that are infra-manifest-specific and don't apply here.

Verified functionally, not just wired: staged a real high-entropy fake
key and confirmed `git commit` is blocked by the hook
(RuleID=generic-api-key); staged a well-known documented-example AWS
key (AKIAIOSFODNN7EXAMPLE) first and confirmed gitleaks correctly
passes it (it's not a real leak, and gitleaks' own default rules know
that) -- worth recording since a naive fixture check would have looked
like detection was broken.

Enable per clone: git config core.hooksPath .githooks
2026-08-28 23:58:05 +02:00
mathiasandClaude Opus 5 670325e519 ci(deploy): refuse to patch a manifest with an empty IMAGE_TAG (infra#311)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s
On 2026-08-25 a deploy job ran with an empty IMAGE_TAG, wrote
`image: localhost:5000/gitea-mcp:` into the infra repo and pushed it.
That is invalid YAML, so Flux failed the ENTIRE apps Kustomization --
every app stopped reconciling for about an hour, including three
unrelated deploys queued behind it.

This job's existing check could not catch it. It greps for
`image: <repo>:${IMAGE_TAG}` after the sed, and with IMAGE_TAG empty that
pattern matches the corrupted line itself, so the verification passes
because of the bug rather than despite it.

Asserting the input up front is the fix: an empty tag now fails the job
before anything is written, let alone committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011PL5XcG68SvQqz3vGVbWbf
2026-08-25 17:25:25 +02:00
54 changed files with 1845 additions and 404 deletions
+23
View File
@@ -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}"
}
} }
} }
} }
+75 -19
View File
@@ -53,14 +53,30 @@ These rules apply to every task across every project, regardless of harness.
logical change (one tool, one fix, one test) with passing tests. Main is always logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches. deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be **The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
actively working on the same repo simultaneously, create a short-lived branch main with CI as the gate — that is most work and is unchanged. `risk/medium` and
(`agent/<description>`), finish the task, and merge to main within the same above opens a PR, even solo, even with nobody waiting to review it. This mirrors
session. Do not leave agent branches open between sessions. the tiered merge model in `assessor-loop`
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
what it sells instead of merging everything on green and claiming otherwise.
**Exception — external contributor or client four-eyes requirement:** Use **Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
PR flow only when a human reviewer outside the project is required. Document collisions between parallel sessions: two were not branching problems at all — a
the reason in PROJECT.md. generated artifact written to the same path by both (branching turns that into a
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
push resolved by rebase in seconds. Use a worktree per session on shared repos and
timestamp generated artifacts. Do not reach for a branch.
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
issues, review comments live on a thread separate from the issue's, and a
merged-and-deleted branch cannot be amended (brain:
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
**Exception — external contributor or client four-eyes requirement:** Use PR flow
when a human reviewer outside the project is required. Document the reason in
PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping 6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted: the code is not the end of the task; capturing it is. Run this unprompted:
@@ -78,6 +94,18 @@ These rules apply to every task across every project, regardless of harness.
- Surface the brain entries and issue numbers in the closing summary so the - Surface the brain entries and issue numbers in the closing summary so the
trail is auditable. trail is auditable.
**Chat is not a durable store. Never rely on Mathias remembering anything.**
Every gap, caveat, risk, open question and next step must land in one of four
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
lost the moment the session closes — and this estate has repeatedly proved that a
remembered convention is an unreliable control.
This applies hardest to what is tempting to leave as a closing remark: "worth
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -148,10 +176,10 @@ Three machines on Tailscale:
|---------|------|-----------| |---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector | | koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac | | iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here | | flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted) - **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines - **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
- **Networking**: Tailscale mesh - **Networking**: Tailscale mesh
## Project landscape ## Project landscape
@@ -247,10 +275,11 @@ When working on a project tagged with a client name:
## Harness-agnostic principles ## Harness-agnostic principles
This context is designed to work with any AI coding tool: Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush re-add adapters for them:
- Pi Coding Agent, Mistral Vibe, Antigravity - Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
- Any tool that accepts a system prompt or reads a markdown context file - Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
- Any other tool that reads `AGENTS.md`, which is the de facto convention
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project). The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup. Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
@@ -262,15 +291,29 @@ Canonical sources of truth:
- Project: `<repo>/.context/PROJECT.md` (per-repo) - Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`): Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`, - `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
`.context/system-prompt.txt`
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
you touch the repo, along with their entries in that repo's `task check`
assertion.
Workflow: Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and 1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push. derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking) 2. On any other host, `git pull` brings both. Which file a harness reads does
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`; NOT split cleanly by lookup strategy, so do not reason from the strategy:
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`. - `CLAUDE.md` — Claude Code (walks up the tree)
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
up** from cwd and takes the first `AGENTS.md` it finds
(user-level `~/.vibe/AGENTS.md`)
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
any harness, it is for pasting
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
inside that repo, so this works — but by construction, not by luck.
3. `task check` runs `context:sync` then asserts `git status --porcelain` 3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a and missing-untracked adapters). A drift fails the check with a
@@ -281,7 +324,20 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list. Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
**Skill trigger table — load before starting, not after getting stuck:** **Skill trigger table — load before starting, not after getting stuck:**
-1
View File
@@ -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
+19 -5
View File
@@ -45,6 +45,12 @@ jobs:
set -e set -e
trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT trap 'rm -rf /tmp/infra-update; rm -f ~/.ssh/infra_deploy_key' EXIT
IMAGE_TAG="${{ github.sha }}" IMAGE_TAG="${{ github.sha }}"
# infra#311: github.sha is never empty in practice, but the manifest
# patch below is the same shape that took Flux down estate-wide on
# 2026-08-25 -- an empty tag yields `image: <repo>:`, which is invalid
# YAML and fails the ENTIRE Kustomization, not just this app. The
# assert costs nothing and removes the class.
[ -n "${IMAGE_TAG:-}" ] || { echo "IMAGE_TAG is empty - refusing to patch"; exit 1; }
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
chmod 600 ~/.ssh/infra_deploy_key chmod 600 ~/.ssh/infra_deploy_key
@@ -75,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: |
+6 -1
View File
@@ -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]
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Pre-commit secret scan for the hyperguild repo.
#
# task check -> security:gitleaks enforces this in CI, but CI is after the
# push. This catches a staged secret before it ever leaves the machine --
# the infra#39 rotation pass found exactly this class of leak (a printed
# credential, committed, archived into git) and there was no local gate to
# have caught it. Mirrors infra's own .githooks/pre-commit (infra#326).
#
# Enable once per clone: git config core.hooksPath .githooks
# Bypass if absolutely needed: git commit --no-verify
set -euo pipefail
GITLEAKS="$(command -v gitleaks || true)"
if [[ -z "$GITLEAKS" && -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ]]; then
GITLEAKS="$(go env GOPATH)/bin/gitleaks"
fi
if [[ -z "$GITLEAKS" ]]; then
echo "⚠ gitleaks not installed — skipping local secret scan (CI still enforces it)."
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
exit 0
fi
echo "→ Scanning staged changes for secrets..."
if ! "$GITLEAKS" protect --staged --redact --config .gitleaks.toml >/dev/null 2>&1; then
echo " ✗ gitleaks found a secret in the staged changes."
echo " Review with: gitleaks protect --staged --redact --config .gitleaks.toml -v"
echo " If it is a false positive, allowlist it in .gitleaks.toml or add a"
echo " '# gitleaks:allow' comment. Do NOT --no-verify a real secret."
exit 1
fi
echo " ✓ no staged secrets"
+13 -5
View File
@@ -56,8 +56,16 @@ paths = [
'''ingestion/internal/claudewatcher/watcher_test\.go$''', '''ingestion/internal/claudewatcher/watcher_test\.go$''',
] ]
[[allowlists]] # infra#344: the allowlist that used to sit here is gone, and so is the reason
description = "Literal placeholder word REDACTED matched as if it were a token (verified 2026-08-04: extracted Secret == 'REDACTED' exactly, gitleaks' curl-auth-header rule matched the placeholder text itself, not a real credential)" # for it. It suppressed docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md
condition = "AND" # on the stated grounds that "extracted Secret == 'REDACTED' exactly ... the
paths = ['''docs/superpowers/plans/2026-04-22-phase4-attempt-wiring\.md$'''] # placeholder text itself, not a real credential" (verified 2026-08-04). That was
regexes = ['''REDACTED'''] # not what the file contained: line 1054 carried a real 40-char hex Gitea PAT in
# an `Authorization: token ...` header. Confirmed dead by side-effect — GET
# /api/v1/user with it returns 401 — and redacted in that file, so there is now
# nothing to allowlist.
#
# The `paths` entry also made the block far broader than its description: gitleaks
# ORs global allowlist clauses and ignores `condition = "AND"`, so the whole file
# was immune, not just lines matching /REDACTED/. A live token added to that doc
# would have been suppressed on the strength of a note about a different value.
+33
View File
@@ -0,0 +1,33 @@
# Historical gitleaks findings, dispositioned (infra#326 / infra#317).
#
# WHY THIS FILE AND NOT .gitleaks.toml:
# This is a finding in git HISTORY, surfaced by the full-history audit
# (`task security:gitleaks:history`). The working-tree scan that gates commits
# (`task security:gitleaks`, the pre-commit hook, CI) is clean. Suppressing by
# PATH in .gitleaks.toml would also blind the working-tree scan for that file
# forever; a fingerprint pins one finding in one commit, so a NEW secret in the
# same file still fires.
#
# History is not rewritten here, so the remediation for a real leak is
# ROTATION -- making the committed value worthless -- never scrubbing.
#
# Dispositioned 2026-08-29. Re-check with: task security:gitleaks:history
# --- Real credential, ALREADY DEAD -------------------------------------------
# A Gitea PAT pasted into a CD-watching command in a planning doc
# (`Authorization: token <40-hex>`, last_eight 3105a670).
#
# Verified dead 2026-08-29: authenticating against
# GET https://git.d-ma.be/api/v1/user with this exact value returns 401, so the
# committed token no longer grants anything. It was almost certainly invalidated
# by the early-August rotation of the `mathias` PATs (the same rotation whose
# fan-out caused infra#260, where CI copies of the old value kept failing).
#
# No rotation action outstanding. Kept as a fingerprint rather than removed from
# history because history cannot be rewritten here.
#
# The generalisable lesson is recorded separately: a credential printed into a
# doc/transcript is permanent, so the only remedy is rotating it -- which in
# this case had already happened for unrelated reasons, by luck rather than by
# the leak being noticed.
c9310b1079478bf4430b0a2dfb25445d7ccc9b59:docs/superpowers/plans/2026-04-22-phase4-attempt-wiring.md:curl-auth-header:1053
+75 -19
View File
@@ -48,14 +48,30 @@ These rules apply to every task across every project, regardless of harness.
logical change (one tool, one fix, one test) with passing tests. Main is always logical change (one tool, one fix, one test) with passing tests. Main is always
deployable. Never create long-lived feature branches. deployable. Never create long-lived feature branches.
**Exception — parallel agents on same repo:** If another agent is known to be **The PR trigger is RISK, not concurrency.** `risk/low` work commits straight to
actively working on the same repo simultaneously, create a short-lived branch main with CI as the gate — that is most work and is unchanged. `risk/medium` and
(`agent/<description>`), finish the task, and merge to main within the same above opens a PR, even solo, even with nobody waiting to review it. This mirrors
session. Do not leave agent branches open between sessions. the tiered merge model in `assessor-loop`
`method/transfer-to-a-regulated-organisation.md` §4.1, so the estate practises
what it sells instead of merging everything on green and claiming otherwise.
**Exception — external contributor or client four-eyes requirement:** Use **Concurrency is NOT a reason to branch.** Measured 2026-08-29 across three real
PR flow only when a human reviewer outside the project is required. Document collisions between parallel sessions: two were not branching problems at all — a
the reason in PROJECT.md. generated artifact written to the same path by both (branching turns that into a
merge conflict on a 33k-line JSON), and uncommitted work in a shared checkout
(a per-session worktree fixes it) — and the third was an ordinary non-fast-forward
push resolved by rebase in seconds. Use a worktree per session on shared repos and
timestamp generated artifacts. Do not reach for a branch.
Branch+PR flow carries documented costs on solo repos: merging auto-closes gated
issues, review comments live on a thread separate from the issue's, and a
merged-and-deleted branch cannot be amended (brain:
`gitea-pr-merge-autoclose-and-review-comment-footguns`). Those are the price of
the `risk/medium`+ lane — paid deliberately, not incurred by accident.
**Exception — external contributor or client four-eyes requirement:** Use PR flow
when a human reviewer outside the project is required. Document the reason in
PROJECT.md.
6. **Close the loop — every substantive task ends with the same ritual.** Shipping 6. **Close the loop — every substantive task ends with the same ritual.** Shipping
the code is not the end of the task; capturing it is. Run this unprompted: the code is not the end of the task; capturing it is. Run this unprompted:
@@ -73,6 +89,18 @@ These rules apply to every task across every project, regardless of harness.
- Surface the brain entries and issue numbers in the closing summary so the - Surface the brain entries and issue numbers in the closing summary so the
trail is auditable. trail is auditable.
**Chat is not a durable store. Never rely on Mathias remembering anything.**
Every gap, caveat, risk, open question and next step must land in one of four
places before the turn ends: **the brain**, **a repo** (code, docs, ADR),
**a tracker issue**, or **a plan file**. A finding stated only in conversation is
lost the moment the session closes — and this estate has repeatedly proved that a
remembered convention is an unreliable control.
This applies hardest to what is tempting to leave as a closing remark: "worth
knowing X isn't enforced yet", "this is a convention, not a gate", "we should
revisit Y". Those are exactly the items that need a locator. If it is worth
saying in a summary, it is worth writing where it can be found again without you.
## CAD sprint patterns ## CAD sprint patterns
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`): Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
@@ -143,10 +171,10 @@ Three machines on Tailscale:
|---------|------|-----------| |---------|------|-----------|
| koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector | | koala | GPU inference, heavy compute | RTX 5070, runs k3s + llama-swap + shared postgres18/pgvector |
| iguana | Services, builds | M2 Ultra Mac | | iguana | Services, builds | M2 Ultra Mac |
| flamingo | Daily driver, edge | Mac mini, ~/dev is here | | flamingo | Daily driver, edge | Apple Silicon Mac, ~/dev is here |
- **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted) - **Model routing**: LiteLLM in front of llama-swap (local) + cloud APIs (when permitted)
- **Orchestration**: k3s cluster across all three machines - **Orchestration**: k3s on koala only — single control-plane node, no workers (verified 2026-08-30). iguana and flamingo are on the tailnet but are not cluster members
- **Networking**: Tailscale mesh - **Networking**: Tailscale mesh
## Project landscape ## Project landscape
@@ -242,10 +270,11 @@ When working on a project tagged with a client name:
## Harness-agnostic principles ## Harness-agnostic principles
This context is designed to work with any AI coding tool: Supported harnesses (2026-09-01) — Cursor and Aider were dropped; do not
- Claude Code, Cursor, Aider, Open WebUI, Charmbracelet Mods/Crush re-add adapters for them:
- Pi Coding Agent, Mistral Vibe, Antigravity - Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush, Pi Coding Agent
- Any tool that accepts a system prompt or reads a markdown context file - Open WebUI, Charmbracelet Mods, or any tool that takes a system prompt string
- Any other tool that reads `AGENTS.md`, which is the de facto convention
The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project). The canonical source is always `.context/AGENT.md` (root) and `.context/PROJECT.md` (per-project).
Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup. Derived files are committed (see *How context propagates* below) so a `git pull` on any host yields full agent context with no setup.
@@ -257,15 +286,29 @@ Canonical sources of truth:
- Project: `<repo>/.context/PROJECT.md` (per-repo) - Project: `<repo>/.context/PROJECT.md` (per-repo)
Derived files (committed, regenerated by `task context:sync`): Derived files (committed, regenerated by `task context:sync`):
- `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.aider.conventions.md`, - `CLAUDE.md`, `AGENTS.md`, `.context/system-prompt.txt`
`.context/system-prompt.txt`
Repos still carrying `.cursorrules`, `.aider.conventions.md` or
`.aider.conf.yml` are stale: nothing regenerates them. Delete them the next time
you touch the repo, along with their entries in that repo's `task check`
assertion.
Workflow: Workflow:
1. Edit a canonical file. Run `task context:sync`. Commit canonical and 1. Edit a canonical file. Run `task context:sync`. Commit canonical and
derived together. Push. derived together. Push.
2. On any other host, `git pull` brings both. Claude Code (tree-walking) 2. On any other host, `git pull` brings both. Which file a harness reads does
uses `CLAUDE.md`; Crush / Pi / Antigravity (cwd-only) use `AGENTS.md`; NOT split cleanly by lookup strategy, so do not reason from the strategy:
Cursor uses `.cursorrules`; Aider uses `.aider.conventions.md`. - `CLAUDE.md` — Claude Code (walks up the tree)
- `AGENTS.md` — Crush, Pi, Antigravity (cwd-only), OpenCode (project root,
global at `~/.config/opencode/AGENTS.md`), and Mistral Vibe, which **walks
up** from cwd and takes the first `AGENTS.md` it finds
(user-level `~/.vibe/AGENTS.md`)
- `.context/system-prompt.txt` — Open WebUI, Mods, raw API calls; not read by
any harness, it is for pasting
Vibe tree-walks for `AGENTS.md`, so "tree-walkers use CLAUDE.md" is false.
Each repo's `AGENTS.md` embeds the canonical and is the first one found from
inside that repo, so this works — but by construction, not by luck.
3. `task check` runs `context:sync` then asserts `git status --porcelain` 3. `task check` runs `context:sync` then asserts `git status --porcelain`
is empty over the derived files (catches both modified-tracked drift is empty over the derived files (catches both modified-tracked drift
and missing-untracked adapters). A drift fails the check with a and missing-untracked adapters). A drift fails the check with a
@@ -276,7 +319,20 @@ unconditionally on every host, every harness.
## Engineering Skills ## Engineering Skills
Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Clone it to `~/dev/skills/` and run `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh` there to wire every skill into your harnesses (Claude Code, Crush, Antigravity, Mistral Vibe) as native, on-demand skills. (Use `install.sh`, not `task install` — the latter is currently broken, skills#7.) Load at task start — not "on demand" but on schedule, before writing code. Browse `~/dev/skills/SKILLS_INDEX.md` for the full list. Shared engineering skills live in the **`mathias/skills`** repo (`git.d-ma.be/mathias/skills`). Load at task start — not "on demand" but on schedule, before writing code. Browse `SKILLS_INDEX.md` for the full list.
**Claude Code** installs the repo as a plugin from the marketplace the same repo hosts. That path also carries the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra MCP servers, which symlinks cannot:
```
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias
```
Skills are **namespaced** under Claude Code: load `tdd` as `skills:tdd`. The trigger table below uses bare names — prefix them.
**Crush, Pi and OpenCode** have no plugin system and are wired by `install.sh` as one directory symlink per harness via `~/.agents/skills`. Do not clone the checkout into `~/dev`: that folder is iCloud-shared between the Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala), so a checkout there is a git working tree with two concurrent writers, and any absolute symlink stored in it is broken on one side by construction (skills#23). Leave `SKILLS_CHECKOUT_DIR` at its host-local default.
Check a host with `task doctor` in the skills repo; it also runs automatically at session start via the plugin.
**Skill trigger table — load before starting, not after getting stuck:** **Skill trigger table — load before starting, not after getting stuck:**
-1
View File
@@ -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/
+38 -36
View File
@@ -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,23 +74,48 @@ 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:
- task: context:sync # Context sync is skipped under CI, matching dma-cli, gitea-mcp, and the
# rest of the estate. It is not a preference: context-sync.sh walks UP
# the tree for the canonical .context/AGENT.md, which lives in local-dev
# and does not exist on a CI runner. Without this guard, the script
# silently falls back to "project context only" and regenerates AGENTS.md
# and .context/system-prompt.txt shorter than their committed, correct
# versions -- so the drift assertion fails every CI run for a purely
# environmental reason, not real drift. Verified, not assumed: this is
# exactly what was happening here (AGENTS.md 430 lines committed vs. an
# 82-line regeneration on the runner).
#
# The cost is real and worth stating: drift is therefore only ever caught
# on a developer machine, or by infra's estate-wide context-sync-drift
# CronJob (ADR-0019), which does have canonical access and is the actual
# enforcement mechanism now.
- cmd: | - cmd: |
drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null) if [ -n "${CI:-}" ]; then
if [ -n "$drift" ]; then echo "✓ context sync: skipped in CI (no canonical AGENT.md on the runner)"
echo "ERROR: derived adapters drifted from canonical context." >&2 else
echo "$drift" >&2 bash scripts/context-sync.sh
echo "" >&2 drift=$(git status --porcelain -- AGENTS.md CLAUDE.md .context/system-prompt.txt 2>/dev/null)
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2 if [ -n "$drift" ]; then
echo " git commit -m 'chore: re-sync context adapters'" >&2 echo "ERROR: derived adapters drifted from canonical context." >&2
exit 1 echo "$drift" >&2
echo "" >&2
echo "Run: git add AGENTS.md CLAUDE.md .context/system-prompt.txt" >&2
echo " git commit -m 'chore: re-sync context adapters'" >&2
exit 1
fi
echo "✓ context: canonical and adapters are in sync"
fi fi
echo "✓ context: canonical and adapters are in sync"
- 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 ─────────────────────────────────────────────
@@ -154,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:
-26
View File
@@ -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
@@ -1051,7 +1051,7 @@ git push
```bash ```bash
# Poll until complete (replace RUN_ID with the new run): # Poll until complete (replace RUN_ID with the new run):
curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \ curl -s "https://gitea.d-ma.be/api/v1/repos/mathias/hyperguild/actions/runs?limit=2" \
-H "Authorization: token 736a8c36adcc6ecb41fff56e5ae4d0eb3105a670" \ -H "Authorization: token <REDACTED-dead-gitea-pat-infra#344>" \
| python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]" | python3 -c "import sys,json; [print(r['id'],r['status'],r.get('conclusion','—')) for r in json.load(sys.stdin)['workflow_runs']]"
``` ```
+35 -6
View File
@@ -236,6 +236,11 @@ func main() {
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b") fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout) router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
} }
if tertiaryURL := os.Getenv("BRAIN_LLM_TERTIARY_URL"); tertiaryURL != "" {
tertiaryModel := envOr("BRAIN_LLM_TERTIARY_MODEL", "gemini-2.5-flash")
tertiaryKey := os.Getenv("GEMINI_API_KEY")
router.Tertiary = llm.New(tertiaryURL, tertiaryKey, tertiaryModel, timeout)
}
answerComplete = router.Complete answerComplete = router.Complete
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel) logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
} }
@@ -391,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)
} }
+10
View File
@@ -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)
} }
+42
View File
@@ -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]]")
}
+43 -1
View File
@@ -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)
} }
+108
View File
@@ -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",
+2 -2
View File
@@ -28,8 +28,8 @@ type FileBuffer struct {
} }
type bufferLine struct { type bufferLine struct {
ID string `json:"id"` ID string `json:"id"`
Entry capture.AuditEntry `json:"entry"` Entry capture.AuditEntry `json:"entry"`
} }
// NewFileBuffer returns a buffer backed by path. The parent directory is // NewFileBuffer returns a buffer backed by path. The parent directory is
+4 -4
View File
@@ -46,10 +46,10 @@ func (f *fakeNotifier) Notify(_ context.Context, msg string) error {
// unwritableBuffer always reports it cannot be written (floor condition). // unwritableBuffer always reports it cannot be written (floor condition).
type unwritableBuffer struct{} type unwritableBuffer struct{}
func (unwritableBuffer) Writable() error { return errors.New("disk full") } func (unwritableBuffer) Writable() error { return errors.New("disk full") }
func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") } func (unwritableBuffer) Append(capture.AuditEntry) error { return errors.New("disk full") }
func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil } func (unwritableBuffer) Pending() ([]audit.Buffered, error) { return nil, nil }
func (unwritableBuffer) Confirm(string) error { return nil } func (unwritableBuffer) Confirm(string) error { return nil }
func newFileBuffer(t *testing.T) *audit.FileBuffer { func newFileBuffer(t *testing.T) *audit.FileBuffer {
t.Helper() t.Helper()
+157
View File
@@ -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)
}
+22 -4
View File
@@ -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 {
+57 -11
View File
@@ -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)
}
+14 -2
View File
@@ -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]]")
}
+4
View File
@@ -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)
}
+59 -2
View File
@@ -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") {
+15 -4
View File
@@ -5,14 +5,18 @@ import (
"fmt" "fmt"
) )
// Router calls Primary first; on any error falls back to Fallback. // Router calls Primary first; on any error falls back to Fallback, then to
// Fallback may be nil, in which case primary errors are returned directly. // Tertiary. Fallback and Tertiary may each be nil — a nil tier is skipped and
// the chain returns the errors accumulated so far.
type Router struct { type Router struct {
Primary *Client Primary *Client
Fallback *Client Fallback *Client
Tertiary *Client
} }
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback. // Complete implements pipeline.CompleteFunc, routing through Primary, then
// Fallback, then Tertiary — trying each configured tier in order and
// returning the first success.
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) { func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
out, err := r.Primary.Complete(ctx, system, user) out, err := r.Primary.Complete(ctx, system, user)
if err == nil { if err == nil {
@@ -22,8 +26,15 @@ func (r *Router) Complete(ctx context.Context, system, user string) (string, err
return "", fmt.Errorf("primary llm: %w", err) return "", fmt.Errorf("primary llm: %w", err)
} }
out, err2 := r.Fallback.Complete(ctx, system, user) out, err2 := r.Fallback.Complete(ctx, system, user)
if err2 != nil { if err2 == nil {
return out, nil
}
if r.Tertiary == nil {
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2) return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
} }
out, err3 := r.Tertiary.Complete(ctx, system, user)
if err3 != nil {
return "", fmt.Errorf("primary llm: %w; fallback llm: %v; tertiary llm: %v", err, err2, err3)
}
return out, nil return out, nil
} }
+75
View File
@@ -69,3 +69,78 @@ func TestRouter_NilFallback(t *testing.T) {
_, err := r.Complete(context.Background(), "sys", "user") _, err := r.Complete(context.Background(), "sys", "user")
assert.Error(t, err) assert.Error(t, err)
} }
func TestRouter_FallsBackToTertiaryWhenPrimaryAndFallbackFail(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer fail.Close()
tertiary := mockServer(t, "from-tertiary")
defer tertiary.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fail.URL, "", "m", time.Second),
Tertiary: New(tertiary.URL, "", "m", time.Second),
}
out, err := r.Complete(context.Background(), "sys", "user")
require.NoError(t, err)
assert.Equal(t, "from-tertiary", out)
}
func TestRouter_TertiaryNotCalledWhenFallbackSucceeds(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer fail.Close()
fallback := mockServer(t, "from-fallback")
defer fallback.Close()
tertiary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("tertiary must not be called when fallback succeeds")
}))
defer tertiary.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fallback.URL, "", "m", time.Second),
Tertiary: New(tertiary.URL, "", "m", time.Second),
}
out, err := r.Complete(context.Background(), "sys", "user")
require.NoError(t, err)
assert.Equal(t, "from-fallback", out)
}
func TestRouter_AllThreeFail(t *testing.T) {
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "err", http.StatusBadGateway)
}))
defer fail.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fail.URL, "", "m", time.Second),
Tertiary: New(fail.URL, "", "m", time.Second),
}
_, err := r.Complete(context.Background(), "sys", "user")
assert.Error(t, err)
assert.Contains(t, err.Error(), "primary llm")
assert.Contains(t, err.Error(), "fallback llm")
assert.Contains(t, err.Error(), "tertiary llm")
}
func TestRouter_NilTertiary(t *testing.T) {
// Existing 2-tier deployments (no BRAIN_LLM_TERTIARY_URL) must keep working
// exactly as before — Tertiary is opt-in, not required.
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "err", http.StatusBadGateway)
}))
defer fail.Close()
r := &Router{
Primary: New(fail.URL, "", "m", time.Second),
Fallback: New(fail.URL, "", "m", time.Second),
}
_, err := r.Complete(context.Background(), "sys", "user")
assert.Error(t, err)
assert.NotContains(t, err.Error(), "tertiary")
}
+22 -3
View File
@@ -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 {
+2
View File
@@ -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
} }
+8
View File
@@ -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)
+4 -4
View File
@@ -32,12 +32,12 @@ type Entry struct {
type Attempt struct { type Attempt struct {
Attempt int `json:"attempt"` Attempt int `json:"attempt"`
Model string `json:"model"` Model string `json:"model"`
Tier string `json:"tier"` // local | subagent | managed Tier string `json:"tier"` // local | subagent | managed
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
Verified bool `json:"verified"` Verified bool `json:"verified"`
Verdict string `json:"verdict,omitempty"` // accept | escalate | error Verdict string `json:"verdict,omitempty"` // accept | escalate | error
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
OutputSummary string `json:"output_summary,omitempty"` OutputSummary string `json:"output_summary,omitempty"`
RunnerOutput string `json:"runner_output,omitempty"` RunnerOutput string `json:"runner_output,omitempty"`
} }
@@ -18,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)
} }
+3 -2
View File
@@ -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")
+172 -34
View File
@@ -3,6 +3,7 @@ package wiki
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
) )
@@ -29,12 +30,16 @@ type section struct {
} }
// Merge combines two Page values with the same path. // Merge combines two Page values with the same path.
// Frontmatter is taken from a. Sections are merged by strategy: // Frontmatter is taken from a. The preamble (content before the first ##
// bullet sections union unique lines, append sections concatenate, // heading) is taken from a — same "on-disk wins" default every unlisted
// all others keep a's version. Sections in b not present in a are appended. // named section gets, kept as a separate field rather than a section with
// an empty heading so it can never collide with one (see parseSections).
// Sections are merged by strategy: bullet sections union unique lines,
// append sections concatenate, all others keep a's version. Sections in b
// not present in a are appended.
func Merge(a, b Page) Page { func Merge(a, b Page) Page {
fmA, secsA := parseSections(a.Content) fmA, preambleA, secsA := parseSections(a.Content)
fmB, secsB := parseSections(b.Content) fmB, _, secsB := parseSections(b.Content)
idx := make(map[string]int, len(secsA)) idx := make(map[string]int, len(secsA))
for i, s := range secsA { for i, s := range secsA {
@@ -57,7 +62,7 @@ func Merge(a, b Page) Page {
} }
} }
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), secsA)} return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}
} }
// generatorOwned names the frontmatter fields the ingest pipeline may refresh // generatorOwned names the frontmatter fields the ingest pipeline may refresh
@@ -77,39 +82,144 @@ var generatorOwned = map[string]bool{
"source_hash": true, "source_hash": true,
} }
// fmLine is one line of a frontmatter block: a parsed key/value pair, or a
// raw line (blank, comment, or anything else that isn't a simple "key:
// value") kept verbatim. Same shape as api.frontmatter's fmLine — this
// package doesn't import that one (it's unexported there, and a's/b's
// frontmatter here always arrives as a string, not a parsed struct), so the
// small, already-proven set()-appends-if-absent behavior is reproduced
// locally rather than string-index surgery reinvented per call site.
type fmLine struct {
key, value, raw string
}
// parseFrontmatterBlock parses a "---\n...\n---\n" fenced block (the shape
// parseSections produces) into an ordered line list. A block with no
// opening/closing fence — including the empty string, a page with no
// frontmatter at all — returns nil, ok=false: there is nothing to update in
// place, only something to potentially create.
func parseFrontmatterBlock(block string) (lines []fmLine, ok bool) {
if !strings.HasPrefix(block, "---\n") {
return nil, false
}
rest := block[len("---\n"):]
end := strings.Index(rest, "\n---\n")
if end < 0 {
return nil, false
}
for _, line := range strings.Split(rest[:end], "\n") {
// An indented line is a continuation (a YAML block-list item, a
// wrapped value) never a top-level key, regardless of whether it
// happens to contain a colon — " - 'Chapter 3: The Reckoning'"
// is not a key named "- 'Chapter 3". Checked before Cut so a
// colon inside one never reaches the key/value path at all.
if line != "" && (line[0] == ' ' || line[0] == '\t') {
lines = append(lines, fmLine{raw: line})
continue
}
key, val, found := strings.Cut(line, ":")
key = strings.TrimSpace(key)
if !found || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
lines = append(lines, fmLine{raw: line})
continue
}
lines = append(lines, fmLine{key: key, value: strings.TrimSpace(val)})
}
return lines, true
}
// setLine updates key's value in place if lines already has it, or appends
// a new entry if it doesn't — the one behavior the original refreshOwned
// got wrong (PR #126 review, round 2: it only ever updated in place).
func setLine(lines []fmLine, key, value string) []fmLine {
for i := range lines {
if lines[i].key == key {
lines[i].value = value
return lines
}
}
return append(lines, fmLine{key: key, value: value})
}
func renderFrontmatterBlock(lines []fmLine) string {
if len(lines) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("---\n")
for _, l := range lines {
switch {
case l.key == "":
sb.WriteString(l.raw)
case l.value == "":
// A key with nothing after the colon (the YAML block-list shape:
// "aliases:" followed by indented "- item" lines on their own
// raw lines) must stay bare — "key: " with a trailing space
// is a different, if invisible, byte sequence, and this file's
// whole purpose is byte-stability across an unrelated merge.
sb.WriteString(l.key)
sb.WriteByte(':')
default:
sb.WriteString(l.key)
sb.WriteString(": ")
sb.WriteString(l.value)
}
sb.WriteByte('\n')
}
sb.WriteString("---\n")
return sb.String()
}
// refreshOwned returns a's frontmatter with the generator-owned fields taken // 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 // 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. // generator that stops emitting a field cannot blank it.
//
// A field b supplies that a doesn't have yet — every page ingested before
// this mechanism existed has no source_hash line at all — is appended, not
// dropped (round 1 of review: dropping it silently meant source_hash never
// populated for the pre-existing corpus). If a has no frontmatter fence at
// all, one is created to hold the new fields rather than splicing raw
// "key: value" text into the body (round 2: that corrupted the page and the
// fields would duplicate on every subsequent merge, since unfenced text
// isn't recognized as frontmatter by parseSections on the next pass).
func refreshOwned(a, b string) string { func refreshOwned(a, b string) string {
bLines, _ := parseFrontmatterBlock(b)
bVals := make(map[string]string) bVals := make(map[string]string)
for _, line := range strings.Split(b, "\n") { for _, l := range bLines {
key, value, found := strings.Cut(line, ":") if l.key != "" && generatorOwned[l.key] {
if !found || !generatorOwned[strings.TrimSpace(key)] { bVals[l.key] = l.value
continue
} }
bVals[strings.TrimSpace(key)] = strings.TrimSpace(value)
} }
if len(bVals) == 0 { if len(bVals) == 0 {
return a return a
} }
out := make([]string, 0, len(strings.Split(a, "\n"))) aLines, _ := parseFrontmatterBlock(a) // ok==false (no fence) -> aLines nil, same as "start fresh"
for _, line := range strings.Split(a, "\n") {
key, _, found := strings.Cut(line, ":") // Sorted for a deterministic, byte-stable result when more than one
k := strings.TrimSpace(key) // field is appended in the same call — map iteration order isn't.
if found && generatorOwned[k] { keys := make([]string, 0, len(bVals))
if v, ok := bVals[k]; ok { for k := range bVals {
out = append(out, k+": "+v) keys = append(keys, k)
delete(bVals, k)
continue
}
}
out = append(out, line)
} }
return strings.Join(out, "\n") sort.Strings(keys)
for _, k := range keys {
aLines = setLine(aLines, k, bVals[k])
}
return renderFrontmatterBlock(aLines)
} }
func parseSections(markdown string) (frontmatter string, sections []section) { // parseSections splits markdown into its frontmatter, its preamble (content
// before the first `## ` heading), and its `## `-headed sections.
//
// The preamble is returned separately rather than as a section with an
// empty heading — a section keyed by heading=="" would collide with a
// literal degenerate `## ` (marker, no title text; strings.TrimPrefix also
// yields ""), which is a distinct, addressable section a page can genuinely
// have, not the same thing as "no heading yet." Two entries sharing one key
// in Merge's idx map means one of them silently loses lookup visibility —
// found in review as real, reproducible data loss, not a hypothetical.
func parseSections(markdown string) (frontmatter, preamble string, sections []section) {
lines := strings.Split(markdown, "\n") lines := strings.Split(markdown, "\n")
i := 0 i := 0
@@ -127,6 +237,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]
@@ -153,9 +279,12 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest // 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 // merges on every run, so "merging changes nothing when nothing changed" has to
// hold at the byte level, not just the content level. // hold at the byte level, not just the content level.
func rebuildContent(frontmatter string, sections []section) string { 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 {
body := strings.Trim(sec.content, "\n") body := strings.Trim(sec.content, "\n")
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body) fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
@@ -163,6 +292,15 @@ func rebuildContent(frontmatter string, sections []section) string {
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. // appendNew concatenates the lines of b that a does not already carry.
// //
// Append sections used to concatenate wholesale, which is correct exactly once. // Append sections used to concatenate wholesale, which is correct exactly once.
@@ -183,18 +321,18 @@ func rebuildContent(frontmatter string, sections []section) string {
func appendNew(a, b string) string { func appendNew(a, b string) string {
seen := make(map[string]bool) seen := make(map[string]bool)
for _, line := range strings.Split(a, "\n") { for _, line := range strings.Split(a, "\n") {
if t := strings.TrimSpace(line); t != "" { if c := canonicalLine(line); c != "" {
seen[t] = true seen[c] = true
} }
} }
var add []string var add []string
for _, line := range strings.Split(b, "\n") { for _, line := range strings.Split(b, "\n") {
t := strings.TrimSpace(line) c := canonicalLine(line)
if t == "" || seen[t] { if c == "" || seen[c] {
continue continue
} }
seen[t] = true seen[c] = true
add = append(add, line) add = append(add, line)
} }
if len(add) == 0 { if len(add) == 0 {
@@ -207,11 +345,11 @@ 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"
+189
View File
@@ -54,6 +54,77 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
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 ------------------------- // --- brain#21/B1: merging must be safe to run again -------------------------
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) { func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
@@ -143,3 +214,121 @@ Rewritten by the model.
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite") assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
assert.NotContains(t, got, "Renamed by the model") assert.NotContains(t, got, "Renamed by the model")
} }
func TestMerge_GeneratorOwnedFieldsAreAppendedWhenAPageNeverHadThemBefore(t *testing.T) {
// Found in review of PR #126: refreshOwned only replaced a generator-owned
// line that already existed in a's frontmatter — it never appended one a
// didn't have. Every page ingested before this feature shipped has no
// source_hash line at all, so on its first re-ingest after deploy, the
// fresh hash was silently dropped and the skip-on-unchanged feature this
// PR exists to build would never engage for the entire pre-existing
// corpus, permanently, with nothing telling anyone.
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## 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
// Exact frontmatter block, not Contains: the second review round found
// that a substring check doesn't prove the fields landed inside a valid
// fence, in the right order, or that the fence itself wasn't corrupted —
// two independent mutations (closeIdx targeting the wrong fence, reversed
// sort) both survived a Contains-only version of this test.
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
assert.Equal(t, want, got)
}
func TestMerge_GeneratorOwnedFieldsCreateAFrontmatterBlockWhenThePageHadNoneAtAll(t *testing.T) {
// Second review round on PR #126: when a has no frontmatter fence at all,
// the append fallback spliced raw, unfenced "key: value" lines straight
// into the body -- worse than the original bug, because the next
// parseSections call wouldn't recognize them as frontmatter (they don't
// start with `---`), so they'd be absorbed as ordinary preamble text and
// re-duplicate on every subsequent merge instead of updating in place.
existing := Page{Path: "wiki/sources/x.md", Content: "## Summary\n\nOriginal.\n"}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
last_updated: 2026-08-22
source_hash: bbbb
---
## Summary
Rewritten by the model.
`}
got := Merge(existing, fresh).Content
want := "---\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
assert.Equal(t, want, got)
}
func TestMerge_GeneratorOwnedFieldsMixedReplaceAndAppend(t *testing.T) {
// a already has last_updated (replace in place) but not source_hash
// (append) -- the two code paths in the same call, together.
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
last_updated: 2026-01-01
---
## Summary
Original.
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
last_updated: 2026-08-22
source_hash: bbbb
---
## Summary
Rewritten by the model.
`}
got := Merge(existing, fresh).Content
want := "---\ntitle: 'X'\nlast_updated: 2026-08-22\nsource_hash: bbbb\n---\n\n## Summary\n\nOriginal.\n"
assert.Equal(t, want, got)
}
func TestRefreshOwned_CommentLinesSurviveUnmangled(t *testing.T) {
// Third review round on PR #126: parseFrontmatterBlock dropped the
// sibling api package's comment guard (strings.HasPrefix(trimmed, "#")).
// Without it, a comment line containing a colon gets cut at the colon
// and re-rendered with collapsed whitespace instead of surviving
// verbatim.
a := "---\n#see also: https://x/y\ntitle: 'X'\n---\n"
b := "---\nsource_hash: bbbb\n---\n"
got := refreshOwned(a, b)
assert.Equal(t, "---\n#see also: https://x/y\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
}
func TestRefreshOwned_IndentedListItemsWithColonsAreNotMisparsedAsKeys(t *testing.T) {
// Same round: an indented YAML block-list item that happens to contain a
// colon (e.g. a title with a subtitle) is a continuation line, never a
// top-level key, regardless of what strings.Cut finds in it. Misparsing
// it as a key/value pair and re-rendering strips its indentation,
// de-indenting it out of the list it belongs to -- and refreshOwned
// re-renders this block on essentially every non-skipped ingest, so this
// is the normal path, not an edge case.
a := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\n"
b := "---\nsource_hash: bbbb\n---\n"
got := refreshOwned(a, b)
assert.Equal(t, "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\nsource_hash: bbbb\n---\n", got)
}
+5 -5
View File
@@ -10,10 +10,10 @@ import (
"testing" "testing"
"time" "time"
"git.d-ma.be/mathias/hyperguild/internal/auth"
"github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt" "github.com/lestrrat-go/jwx/v2/jwt"
"git.d-ma.be/mathias/hyperguild/internal/auth"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -89,10 +89,10 @@ func TestValidator(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
tests := []struct { tests := []struct {
name string name string
token string token string
wantSub string wantSub string
wantErr bool wantErr bool
}{ }{
{ {
name: "valid jwt", name: "valid jwt",
-2
View File
@@ -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", "")
-49
View File
@@ -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 ""
}
-53
View File
@@ -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"))
}
+8 -8
View File
@@ -9,14 +9,14 @@ import (
// RoutingConfig holds the runtime configuration for the routing pod. // RoutingConfig holds the runtime configuration for the routing pod.
// Separate from Config because the routing pod's surface differs from the supervisor's. // Separate from Config because the routing pod's surface differs from the supervisor's.
type RoutingConfig struct { type RoutingConfig struct {
Port string // ROUTING_PORT, default 3210 Port string // ROUTING_PORT, default 3210
MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token MCPAuthToken string // ROUTING_MCP_TOKEN, optional bearer token
LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be LiteLLMBaseURL string // LITELLM_BASE_URL, default https://llm-api.d-ma.be
LiteLLMAPIKey string // LITELLM_API_KEY LiteLLMAPIKey string // LITELLM_API_KEY
BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300 BrainURL string // BRAIN_URL, default http://ingestion.supervisor:3300
BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log) BrainMCPToken string // BRAIN_MCP_TOKEN, bearer for the auth-gated ingestion /mcp (session_log)
FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast FastModel string // HYPERGUILD_FAST_MODEL, default koala/qwen35-9b-fast
ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b ThinkingModel string // HYPERGUILD_THINKING_MODEL, default iguana/gemma4-26b
// RouteLocalFloor and RouteLocalCeil intentionally invert the usual // RouteLocalFloor and RouteLocalCeil intentionally invert the usual
// floor < ceil mathematical convention: Floor (default 0.90) is the // floor < ceil mathematical convention: Floor (default 0.90) is the
// UPPER boundary — at/above it, always route local; Ceil (default 0.70) // UPPER boundary — at/above it, always route local; Ceil (default 0.70)
+3 -3
View File
@@ -16,9 +16,9 @@ import (
// Client calls an MCP server over Streamable HTTP / JSON-RPC. // Client calls an MCP server over Streamable HTTP / JSON-RPC.
type Client struct { type Client struct {
url string url string
token string token string
http *http.Client http *http.Client
} }
// ErrTokenRequired is returned by New when token is empty. Empty token // ErrTokenRequired is returned by New when token is empty. Empty token
+1
View File
@@ -22,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
} }
+2 -2
View File
@@ -20,8 +20,8 @@ func TestFormatHistoryFormatsEntries(t *testing.T) {
entries := []session.Entry{ entries := []session.Entry{
{ {
Skill: "tdd", Phase: "red", FinalStatus: "pass", Skill: "tdd", Phase: "red", FinalStatus: "pass",
FilePath: "internal/foo/foo_test.go", FilePath: "internal/foo/foo_test.go",
Message: "wrote failing test for Foo", Message: "wrote failing test for Foo",
Timestamp: time.Now(), Timestamp: time.Now(),
}, },
} }
+4 -4
View File
@@ -32,12 +32,12 @@ type Entry struct {
type Attempt struct { type Attempt struct {
Attempt int `json:"attempt"` Attempt int `json:"attempt"`
Model string `json:"model"` Model string `json:"model"`
Tier string `json:"tier"` // local | subagent | managed Tier string `json:"tier"` // local | subagent | managed
DurationMs int64 `json:"duration_ms"` DurationMs int64 `json:"duration_ms"`
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
Verified bool `json:"verified"` Verified bool `json:"verified"`
Verdict string `json:"verdict,omitempty"` // accept | escalate | error Verdict string `json:"verdict,omitempty"` // accept | escalate | error
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
OutputSummary string `json:"output_summary,omitempty"` OutputSummary string `json:"output_summary,omitempty"`
RunnerOutput string `json:"runner_output,omitempty"` RunnerOutput string `json:"runner_output,omitempty"`
} }
+62 -18
View File
@@ -5,15 +5,29 @@
# For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk) # For Claude Code: generates project-only CLAUDE.md (it inherits root via tree walk)
# For everything else: concatenates root AGENT.md + project PROJECT.md # For everything else: concatenates root AGENT.md + project PROJECT.md
# #
# Usage: ./scripts/context-sync.sh [adapter...] # Cursor and Aider adapters removed 2026-09-01 — neither is a supported harness
# any more. Supported: Claude Code, Antigravity, OpenCode, Mistral Vibe, Crush,
# Pi. All read CLAUDE.md or AGENTS.md; system-prompt.txt remains for Open WebUI,
# Mods and raw API use.
#
# Usage: ./scripts/context-sync.sh [--force] [adapter...]
# Task: task context:sync # Task: task context:sync
# #
# Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh # Override root context: ROOT_CONTEXT=~/dev/.context/AGENT.md ./scripts/context-sync.sh
set -euo pipefail set -euo pipefail
# Parse --force flag and collect adapter names separately
FORCE=false
ADAPTERS=()
for _arg in "$@"; do
case "$_arg" in
--force) FORCE=true ;;
*) ADAPTERS+=("$_arg") ;;
esac
done
PROJECT_FILE=".context/PROJECT.md" PROJECT_FILE=".context/PROJECT.md"
SKILLS_DIR=".skills"
# Walk up to find root .context/AGENT.md # Walk up to find root .context/AGENT.md
find_root_context() { find_root_context() {
@@ -36,19 +50,28 @@ if [ ! -f "$PROJECT_FILE" ]; then
exit 1 exit 1
fi fi
# Pre-flight: reject unfilled {{...}} placeholders unless --force
if [ "$FORCE" = false ]; then
_placeholders=$(grep -n '{{[^}]*}}' "$PROJECT_FILE" 2>/dev/null || true)
if [ -n "$_placeholders" ]; then
echo "Error: unfilled placeholders in $PROJECT_FILE:" >&2
while IFS= read -r _match; do
_lineno="${_match%%:*}"
_content="${_match#*:}"
_token=$(printf '%s' "$_content" | grep -o '{{[^}]*}}' | head -1)
echo " $PROJECT_FILE:$_lineno: unfilled placeholder $_token" >&2
done <<< "$_placeholders"
echo "" >&2
echo "Fill these placeholders, then re-run: task context:sync" >&2
echo "To bypass validation: bash scripts/context-sync.sh --force" >&2
exit 1
fi
fi
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
echo " Root context: $ROOT_CONTEXT" echo " Root context: $ROOT_CONTEXT"
else else
# No reachable root AGENT.md — common in CI's clean checkout. The root+project echo " No root AGENT.md found (project context only)"
# adapters (AGENTS.md, system-prompt.txt) require the root context to
# regenerate correctly, so we skip them entirely and only regenerate CLAUDE.md
# (which is project-only and inherits root via tree walk in Claude Code itself).
echo " No root AGENT.md found — regenerating CLAUDE.md only"
echo "Syncing project context from $PROJECT_FILE..."
cat "$PROJECT_FILE" > CLAUDE.md
echo " → CLAUDE.md (project-only)"
echo "Done."
exit 0
fi fi
# Emit root context + separator # Emit root context + separator
@@ -95,6 +118,7 @@ generate_system_prompt() {
# ── MCP config ─────────────────────────────────────────────── # ── MCP config ───────────────────────────────────────────────
generate_mcp() { generate_mcp() {
# Ensure baseline file exists with project-specific knowledge server
if [ ! -f .context/mcp.json ]; then if [ ! -f .context/mcp.json ]; then
cat > .context/mcp.json << 'JSON' cat > .context/mcp.json << 'JSON'
{ {
@@ -106,27 +130,47 @@ generate_mcp() {
} }
} }
JSON JSON
echo " → .context/mcp.json (new)"
else
echo " → .context/mcp.json (exists, skipped)"
fi fi
# Merge root mcp-servers.json if found alongside root AGENT.md
local root_mcp=""
if [ -n "$ROOT_CONTEXT" ] && [ -f "$ROOT_CONTEXT" ]; then
local candidate
candidate="$(dirname "$ROOT_CONTEXT")/mcp-servers.json"
[ -f "$candidate" ] && root_mcp="$candidate"
fi
if [ -z "$root_mcp" ]; then
echo " → .context/mcp.json (exists, no root mcp-servers.json found)"
return
fi
# Root servers take precedence over project entries on key conflict
local root_servers count updated
root_servers=$(jq '.servers' "$root_mcp")
count=$(printf '%s' "$root_servers" | jq 'keys | length')
updated=$(jq --argjson root "$root_servers" \
'.mcpServers = (.mcpServers + $root)' \
.context/mcp.json)
printf '%s\n' "$updated" > .context/mcp.json
echo " → .context/mcp.json (merged $count root servers)"
} }
echo "Syncing project context from $PROJECT_FILE..." echo "Syncing project context from $PROJECT_FILE..."
if [ $# -eq 0 ]; then if [ ${#ADAPTERS[@]} -eq 0 ]; then
generate_claude generate_claude
generate_agents generate_agents
generate_system_prompt generate_system_prompt
generate_mcp generate_mcp
else else
for adapter in "$@"; do for adapter in "${ADAPTERS[@]}"; do
case "$adapter" in case "$adapter" in
claude) generate_claude ;; claude) generate_claude ;;
agents) generate_agents ;; agents) generate_agents ;;
prompt|system|openwebui|owui|generic) generate_system_prompt ;; prompt|system|openwebui|owui|generic) generate_system_prompt ;;
mcp) generate_mcp ;; mcp) generate_mcp ;;
*) echo "Unknown adapter: $adapter" ;; *) echo "Unknown adapter: $adapter" >&2; exit 1 ;;
esac esac
done done
fi fi
-64
View File
@@ -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"
+2 -1
View File
@@ -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.