Compare commits

...
9 Commits
Author SHA1 Message Date
mathiasandClaude Sonnet 5 6e0155a2ab feat(security): add gitleaks pre-commit scan, wire into task check (infra#39)
CI / Lint / Test / Vet (push) Successful in 1m41s
CI / Mirror to GitHub (push) Successful in 5s
Ported from mathias/infra's .gitleaks.toml (2026-08-04) -- same
homelab bearer/MCP-token rules, plus a BRAIN_PG_DSN pattern for the
hyperguild#20 leak class. Triaged 10 initial findings: 9 were
claudewatcher's own scrubber test fixtures (deliberately fake), 1 was
gitleaks matching the literal placeholder word "REDACTED" in a plan
doc. Both allowlisted with rationale, clean scan confirmed before
wiring into `task check`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016FQX4RwxyxuQY4yf4XaACL
2026-08-04 08:32:42 +02:00
mathiasandClaude Sonnet 5 1cea2c9f78 feat(capture): remove summary->ai-sessions write path
CI / Lint / Test / Vet (push) Successful in 29s
CI / Mirror to GitHub (push) Successful in 4s
ai-sessions#13: capture's `summary` field wrote a frontmatter shape
(title/harness/fidelity/captured_at/repos_touched) that ai-sessions'
own extract/audit pipeline can't parse -- silently invisible to that
repo's own audit tooling, and bypassing its redaction + Stage2
completeness gates by construction. Only 5 files ever landed this way
over 3 weeks; the summary capability's whole value (speed) fights
ai-sessions' whole value (redacted, audited, complete), so kill it
rather than build a second parse branch.

capture now persists insights -> brain and action items -> Gitea
tickets only. Removes Summary/SummaryResult/SummaryWriter and all
wiring (service, REST body, MCP tool schema); close-session updated to
stop assembling a summary payload. specs/capture-*.md kept as historical
record with a superseded note -- the feature shipped and is documented,
just no longer current behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPdSHbp9Utb2hPFm9wDG59
2026-07-27 23:17:56 +02:00
mathiasandClaude Sonnet 5 9dcd60931a chore: retrigger CI after gitea-runner RBAC fix (round 6 of the CI/CD outage chain)
CI / Lint / Test / Vet (push) Successful in 30s
CI / Mirror to GitHub (push) Has been skipped
cd.yml's build+push+ssh chain fully worked last round (infra 707a3c0
landed via CD Bot). Final gap was RBAC: kubectl (new in baked-v6) had
no ServiceAccount permissions. Fixed in infra@949b333 (dedicated SA +
scoped Role/RoleBinding for flux-system + supervisor, verified live
via kubectl auth can-i before this retrigger). No code change here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
2026-07-27 22:29:57 +02:00
mathiasandClaude Sonnet 5 1fac90ed2a fix(cd): pass git.d-ma.be SSH override as -o flags, not an appended ~/.ssh/config line
CI / Lint / Test / Vet (push) Successful in 29s
CI / Mirror to GitHub (push) Successful in 4s
cd.yml's own $HOME (/data) is a PVC that persists across job runs on
this runner. The previous fix (cb9c251) appended the corrected
HostName to ~/.ssh/config, but a stale 127.0.0.1:30022 entry from an
earlier failed run was already there — ssh_config is first-match-wins,
so the stale entry silently shadowed the fix and the very next run
failed identically. Cleaned the persisted stale entry on the runner
directly; this commit removes the append entirely so the step no
longer depends on file state surviving (or not surviving) between
runs — CLI -o overrides always win. Verified end-to-end against the
real infra repo from inside the runner pod (git ls-remote, correctly
reached gitea and got a clean auth rejection, not a connection error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
2026-07-27 22:16:00 +02:00
mathiasandClaude Sonnet 5 cb9c2513a4 fix(cd): use in-cluster DNS for git.d-ma.be SSH, not 127.0.0.1:30022
CI / Lint / Test / Vet (push) Successful in 28s
CI / Mirror to GitHub (push) Successful in 4s
act_runner moved from koala's bare host network into a k3s pod
(2026-07-26); loopback inside that pod's own network namespace never
reaches the host, so the git.d-ma.be SSH override (used to push the
infra repo update) failed with "Connection refused". Verified
TCP+SSH-handshake reachability to gitea-ssh-nodeport.gitea.svc.cluster.local:22
from inside the actual runner pod before committing this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
2026-07-27 22:09:52 +02:00
mathiasandClaude Sonnet 5 3617a6c386 chore: retrigger CI after gitea-runner baked-v6 (ssh/skopeo/buildctl/kubectl)
CI / Lint / Test / Vet (push) Successful in 28s
CI / Mirror to GitHub (push) Successful in 4s
check job passed clean on baked-v5; mirror job then exposed missing
ssh/ssh-keyscan. Fixed in infra@707a3c0, which also preemptively adds
skopeo/buildctl/kubectl for cd.yml's deploy job. No code change here —
retriggering for the full CI->CD end-to-end proof.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
2026-07-27 22:03:07 +02:00
mathiasandClaude Sonnet 5 1938170131 chore: retrigger CI after gitea-runner gcc/libc6-dev fix (baked-v5)
CI / Lint / Test / Vet (push) Successful in 1m12s
CI / Mirror to GitHub (push) Failing after 1s
Runner was missing gcc/libc6-dev too (-race requires cgo). Added in
infra@0f88aea. No code change here — retriggering CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
2026-07-27 21:55:20 +02:00
mathiasandClaude Sonnet 5 b34717e6b8 chore: retrigger CI after gitea-runner image fix (infra#task-baked-v4)
CI / Lint / Test / Vet (push) Failing after 34s
CI / Mirror to GitHub (push) Has been skipped
Runner was missing task (taskfile.dev), added in infra@686c90f. No
code change here — retriggering CI to confirm the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
2026-07-27 21:49:33 +02:00
mathiasandClaude Sonnet 5 d8d7e9a307 chore(context): re-sync derived adapters (Python fallback stack, error-handling note)
CI / Lint / Test / Vet (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
Canonical ~/dev/.context/AGENT.md drifted (Python fallback stack table
row, Python error-handling convention) without a hyperguild commit
re-running task context:sync since — task check's drift gate has been
failing CI on every push since the sprint-1 commits landed, blocking
CD entirely (unrelated to those commits' own content: lint/test/vet
all pass clean, including -race).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Roq1ajWKR5f1hG5Df9wC6A
2026-07-27 15:18:38 +02:00
17 changed files with 175 additions and 208 deletions
+7 -2
View File
@@ -88,7 +88,7 @@ These rules apply to every task across every project, regardless of harness.
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | | — |
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
@@ -97,7 +97,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
+17 -3
View File
@@ -48,9 +48,23 @@ jobs:
mkdir -p ~/.ssh
echo "${{ secrets.INFRA_DEPLOY_KEY }}" > ~/.ssh/infra_deploy_key
chmod 600 ~/.ssh/infra_deploy_key
printf 'Host git.d-ma.be\n HostName 127.0.0.1\n Port 30022\n StrictHostKeyChecking no\n' >> ~/.ssh/config
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes" \
# In-cluster DNS to gitea's SSH NodePort service, not 127.0.0.1:30022
# (that only worked when act_runner ran on koala's bare host network;
# from inside the containerized runner's own pod netns, loopback
# never reaches the host — "Connection refused", found 2026-07-27).
#
# Pass as -o overrides on the ssh invocation itself, NOT appended to
# ~/.ssh/config: $HOME (/data) is a PVC that persists across job
# runs on this runner (same "workspace not ephemeral" class as
# brain: act-runner-host-executor-tmp-persists), so an appended
# line here would pile up duplicate `Host git.d-ma.be` blocks
# across every run — ssh_config is first-match-wins, so a stale
# entry from an earlier failed run would silently shadow this
# fix forever (exactly what happened once already: this fix's
# own first attempt got appended AFTER an already-stale entry
# and lost). CLI -o options always win regardless of file state,
# so this step is safe to re-run any number of times.
GIT_SSH_COMMAND="ssh -i ~/.ssh/infra_deploy_key -o IdentitiesOnly=yes -o HostName=gitea-ssh-nodeport.gitea.svc.cluster.local -o Port=22 -o StrictHostKeyChecking=no" \
git clone "${INFRA_REPO}" /tmp/infra-update
cd /tmp/infra-update
+63
View File
@@ -0,0 +1,63 @@
# gitleaks config for the hyperguild repo (infra#39 — leak prevention pass,
# Phase 3 checklist item: "gitleaks pre-commit hook in infra AND hyperguild").
#
# Ported from mathias/infra's .gitleaks.toml (2026-08-04) — same homelab
# token-shape rules, minus the SOPS/searxng allowlists infra needed (this
# repo doesn't use SOPS).
title = "hyperguild gitleaks config"
[extend]
useDefault = true
# --- Homelab-specific rules -------------------------------------------------
[[rules]]
id = "homelab-static-bearer"
description = "Homelab MCP/LLM static bearer or API key assigned a long literal value"
regex = '''(?i)\b(DMABE_[A-Z0-9_]+|[A-Z0-9_]*MCP_TOKEN|ROUTING_MCP_TOKEN|INFRA_MCP_TOKEN|BRAIN_MCP_TOKEN|GITEA_MCP_TOKEN|LITELLM_MASTER_KEY|LITELLM_SALT_KEY|DMABE_LLMAPI_KEY|BRAIN_PG_DSN)\s*[:=]\s*['"]?([A-Za-z0-9/_+.\-]{16,})['"]?'''
keywords = ["dmabe_", "mcp_token", "litellm_master_key", "litellm_salt_key", "llmapi_key", "brain_pg_dsn"]
[[rules.allowlists]]
description = "Env indirection is not a literal secret"
regexes = [
'''os\.environ''',
'''valueFrom''',
'''secretKeyRef''',
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
'''REDACTED''',
'''<[A-Z_]+>''',
]
[[rules]]
id = "homelab-authorization-bearer"
description = "Hardcoded Authorization: Bearer header"
regex = '''(?i)authorization['"]?\s*[:=]\s*['"]?bearer\s+([A-Za-z0-9/_+.\-=]{16,})'''
keywords = ["authorization", "bearer"]
[[rules.allowlists]]
description = "Env indirection is not a literal secret"
regexes = [
'''\$\{?[A-Za-z_][A-Za-z0-9_]*\}?''',
'''os\.environ''',
'''REDACTED''',
'''<[A-Z_]+>''',
]
# --- Global allowlist: claudewatcher's own scrubber test fixtures ------------
# ingestion/internal/claudewatcher/{scrubber,watcher}_test.go deliberately
# contain fake secret-shaped literals to test that the scrubber detects and
# redacts them. Verified 2026-08-04: all 9 findings here are test fixtures
# (github-pat, jwt, generic-api-key, homelab-authorization-bearer rules) plus
# 1 doc finding that was gitleaks matching the literal placeholder word
# "REDACTED" in a plan doc — not a real secret in either case.
[[allowlists]]
description = "claudewatcher scrubber test fixtures deliberately fake secrets"
paths = [
'''ingestion/internal/claudewatcher/scrubber_test\.go$''',
'''ingestion/internal/claudewatcher/watcher_test\.go$''',
]
[[allowlists]]
description = "Literal placeholder word REDACTED matched as if it were a token (verified 2026-08-04: extracted Secret == 'REDACTED' exactly, gitleaks' curl-auth-header rule matched the placeholder text itself, not a real credential)"
condition = "AND"
paths = ['''docs/superpowers/plans/2026-04-22-phase4-attempt-wiring\.md$''']
regexes = ['''REDACTED''']
+7 -2
View File
@@ -83,7 +83,7 @@ These rules apply to every task across every project, regardless of harness.
| Containers | Docker Compose (dev), k3s (prod) | — | — |
| DB | PostgreSQL + sqlc | SQLite | — |
| Search | pgvector (vector), BM25 | Qdrant (when >1M vectors or hybrid retrieval) | — |
| Logging | slog (structured) | | — |
| Logging | slog (structured) | stdlib `logging` w/ structured `extra=` (or structlog) | — |
| Testing | Table-driven, testify | — | — |
| Agents (Go) | google.golang.org/adk + pkg/litellm adapter | — | — |
@@ -92,7 +92,12 @@ Exploratory: Rust, Zig — I'll tell you when I want these.
## Code conventions
- **Go style**: golines, gofumpt, golangci-lint
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return
- **Python style** (fallback language): ruff (format+lint, one tool), mypy --strict (non-negotiable,
matches Go's static typing discipline), pytest + pytest-cov (table-driven via
`@pytest.mark.parametrize`), uv (venv+deps+lock, one tool), pydantic-settings (typed env-var config
— same principle as Go's typed structs), src-layout + `pyproject.toml` only (no `setup.py`)
- **Errors**: `fmt.Errorf("operation: %w", err)` — never naked, never log-and-return.
Python: `raise X from e` (exception chaining, same principle) — never bare `except`, never silent `pass`
- **Naming**: stdlib conventions, no stuttering
- **Architecture**: prefer stdlib over frameworks, constructor injection, env-var config parsed into typed structs
- **Git**: conventional commits (`feat:`, `fix:`, `chore:`), commit directly to main,
+31
View File
@@ -101,6 +101,37 @@ tasks:
- task: lint
- task: test
- task: vet
- task: security:gitleaks
# ── Security ─────────────────────────────────────────────
security:gitleaks:
desc: Scan the working tree for secrets (gitleaks, fail-closed; skipped if gitleaks absent)
dir: '{{.ROOT_DIR}}'
cmds:
- |
GL="$(command -v gitleaks || true)"
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
if [ -z "$GL" ]; then
echo "⚠ gitleaks not installed — skipping secret scan (CI enforces it)."
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
exit 0
fi
"$GL" detect --no-git --redact --config .gitleaks.toml --source .
security:gitleaks:history:
desc: "One-time FULL-HISTORY secret audit (infra#39 rotation pass; not a per-push gate)"
dir: '{{.ROOT_DIR}}'
cmds:
- |
GL="$(command -v gitleaks || true)"
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
if [ -z "$GL" ]; then
echo "gitleaks not installed: go install github.com/zricethezav/gitleaks/v8@latest" >&2
exit 2
fi
echo "Scanning FULL git history (redacted). Known historical leaks are expected"
echo "until the infra#39 rotation pass completes — triage against the rotation list."
"$GL" detect --redact --config .gitleaks.toml
lint:
cmds:
+1 -5
View File
@@ -457,12 +457,8 @@ func main() {
os.Exit(1)
}
auditSink := buildAuditSink(ctx, brainDir, logger)
// The Gitea client also satisfies SummaryWriter (#66): session
// summaries are written to mathias/ai-sessions over the same API
// token. nil only if a future tracker impl lacks file writes.
summaryWriter, _ := tracker.(capture.SummaryWriter)
captureSvc := capture.NewService(
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink)
mcpSrv.BrainStore(), tracker, classCfg, auditSink)
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
resolver := capturehttp.NewOriginResolver(sovereign)
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
+2 -18
View File
@@ -1,8 +1,7 @@
// Package capture is the Clean-Architecture use-case for the uniform
// capture capability (issue #49/#51): persist a finished session's
// valuable output — insights → brain, action items → Gitea tickets,
// optional summary → ai-sessions — with one invocation, identical core
// behaviour across every harness.
// valuable output — insights → brain, action items → Gitea tickets
// with one invocation, identical core behaviour across every harness.
//
// This package is pure orchestration. It depends only on ports
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
@@ -83,19 +82,11 @@ type Ticket struct {
Body string
}
// Summary is an optional session summary bound for ai-sessions.
type Summary struct {
Title string
Body string
ReposTouched []string
}
// CaptureInput is the whole capture request.
type CaptureInput struct {
Context CaptureContext
Insights []Insight
Tickets []Ticket
Summary *Summary
DryRun bool
}
@@ -117,12 +108,6 @@ type TicketResult struct {
OK bool `json:"ok"`
}
// SummaryResult is the summary outcome in the receipt.
type SummaryResult struct {
Path string `json:"path,omitempty"`
OK bool `json:"ok"`
}
// ItemError pins a failure to a specific request item for the partial
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
type ItemError struct {
@@ -136,7 +121,6 @@ type ItemError struct {
type CaptureReceipt struct {
Insights []InsightResult `json:"insights"`
Tickets []TicketResult `json:"tickets"`
Summary *SummaryResult `json:"summary,omitempty"`
Errors []ItemError `json:"errors"`
EffectiveClassification string `json:"effective_classification,omitempty"`
DryRun bool `json:"dry_run"`
-5
View File
@@ -69,11 +69,6 @@ type IssueTracker interface {
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
}
// SummaryWriter is the ai-sessions summary port.
type SummaryWriter interface {
WriteFile(ctx context.Context, repo, path, content string) error
}
// ClassificationPolicy derives a target's sensitivity (model C). The
// "stricter wins" combination of declared vs derived is use-case policy
// and lives in the service, so the port stays minimal. Satisfied by
+9 -91
View File
@@ -2,8 +2,6 @@ package capture
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
@@ -14,22 +12,19 @@ import (
// Service is the CaptureSession use-case. It depends only on ports.
type Service struct {
brain BrainStore
issues IssueTracker
summaries SummaryWriter
policy ClassificationPolicy
audit AuditSink
brain BrainStore
issues IssueTracker
policy ClassificationPolicy
audit AuditSink
// now is the clock, injectable for deterministic summary paths and
// audit timestamps in tests.
// now is the clock, injectable for deterministic audit timestamps in
// tests.
now func() time.Time
}
// NewService constructs a Service from its ports. summaries may be nil
// when no summary persistence is wired; a CaptureInput with a Summary
// then fails that item rather than panicking.
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
return &Service{brain: b, issues: tr, summaries: sw, policy: p, audit: a, now: time.Now}
// NewService constructs a Service from its ports.
func NewService(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
return &Service{brain: b, issues: tr, policy: p, audit: a, now: time.Now}
}
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
@@ -131,9 +126,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
for _, tk := range in.Tickets {
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
}
if in.Summary != nil {
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
}
return receipt, nil
}
@@ -169,16 +161,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
}
if in.Summary != nil {
res, err := s.persistSummary(ctx, in.Context, in.Summary)
receipt.Summary = &res
if err != nil {
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
} else {
landed = append(landed, "summary:"+res.Path)
}
}
// I5: persist the request-level audit record of exactly what landed,
// using the outcome reserved before the writes. AuditBuffered surfaces
// the degraded (locally-buffered) state on the receipt.
@@ -259,11 +241,6 @@ func (s *Service) resolveClassification(declared classification.Level, in Captur
for _, tk := range in.Tickets {
consider(classification.RepoTarget, tk.Repo)
}
if in.Summary != nil {
for _, repo := range in.Summary.ReposTouched {
consider(classification.RepoTarget, repo)
}
}
return effective, events
}
@@ -309,60 +286,6 @@ func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, e
return res, nil
}
func (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
if s.summaries == nil {
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
}
path := s.summaryPath(c, sum)
content := s.renderSummary(c, sum)
repo := "ai-sessions"
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
return SummaryResult{Path: path, OK: false}, err
}
return SummaryResult{Path: path, OK: true}, nil
}
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
// The ref8 disambiguator is derived from the session_ref (or the title
// when no ref is present) so distinct sessions never collide.
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
t := s.now().UTC()
slug := brain.Sanitise(sum.Title)
if slug == "" {
slug = "summary"
}
seed := c.SessionRef
if seed == "" {
seed = sum.Title + sum.Body
}
sum8 := shortHash(seed)
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
}
// renderSummary stamps fidelity + session metadata into frontmatter so the
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "title: %s\n", sum.Title)
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
if c.SessionRef != "" {
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
}
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
if len(sum.ReposTouched) > 0 {
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
}
b.WriteString("---\n\n")
b.WriteString(sum.Body)
if !strings.HasSuffix(sum.Body, "\n") {
b.WriteByte('\n')
}
return b.String()
}
func kindString(k classification.TargetKind) string {
if k == classification.RepoTarget {
return "repo"
@@ -381,8 +304,3 @@ func firstLine(s string) string {
}
return s
}
func shortHash(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])[:8]
}
+17 -52
View File
@@ -85,21 +85,6 @@ func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _
return IssueRef{Repo: repo, Number: number}, nil
}
type fakeSummary struct {
paths []string
content []string
err error
}
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
if f.err != nil {
return f.err
}
f.paths = append(f.paths, path)
f.content = append(f.content, content)
return nil
}
// fakePolicy derives from an explicit map; default Internal so tests pin
// behaviour without depending on the real defaulting.
type fakePolicy struct{ tags map[string]classification.Level }
@@ -135,8 +120,8 @@ func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) erro
// --- helpers ---
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
s := NewService(b, tr, sw, p, a)
func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
s := NewService(b, tr, p, a)
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
return s
}
@@ -151,7 +136,7 @@ func TestCaptureHappyPath(t *testing.T) {
b := &fakeBrain{}
tr := &fakeTracker{}
au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au)
svc := newSvc(b, tr, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
@@ -180,7 +165,7 @@ func TestCaptureHappyPath(t *testing.T) {
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
b := &fakeBrain{}
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
@@ -197,7 +182,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
b := &fakeBrain{}
tr := &fakeTracker{}
au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au)
svc := newSvc(b, tr, fakePolicy{}, au)
_, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
@@ -216,7 +201,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
}
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
_, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
@@ -239,7 +224,7 @@ func TestCapturePartialFailureBestEffort(t *testing.T) {
}}
tr := &fakeTracker{}
au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au)
svc := newSvc(b, tr, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
@@ -264,7 +249,7 @@ func TestCaptureDryRunWritesNothing(t *testing.T) {
b := &fakeBrain{}
tr := &fakeTracker{}
au := &fakeAudit{}
svc := newSvc(b, tr, nil, fakePolicy{}, au)
svc := newSvc(b, tr, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
@@ -287,7 +272,7 @@ func TestCaptureStricterClassificationWins(t *testing.T) {
b := &fakeBrain{}
au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(b, &fakeTracker{}, nil, pol, au)
svc := newSvc(b, &fakeTracker{}, pol, au)
ctx := baseCtx()
ctx.Classification = "internal"
@@ -306,7 +291,7 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
// Caller declares confidential; target internal → effective confidential, NOT a security event.
au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
ctx := baseCtx()
ctx.Classification = "confidential"
@@ -319,26 +304,6 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
}
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
sw := &fakeSummary{}
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
ctx := baseCtx()
ctx.Fidelity = "transcript-parse"
ctx.SessionRef = "abc123def456"
rec, err := svc.Capture(context.Background(), CaptureInput{
Context: ctx,
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
})
require.NoError(t, err)
require.NotNil(t, rec.Summary)
assert.True(t, rec.Summary.OK)
require.Len(t, sw.paths, 1)
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
assert.Contains(t, sw.paths[0], "session-wrap")
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
}
// --- I1 sovereignty gate (#53) ---
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
@@ -346,7 +311,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
tr := &fakeTracker{}
au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(b, tr, nil, pol, au)
svc := newSvc(b, tr, pol, au)
ctx := baseCtx()
ctx.Classification = "confidential"
@@ -368,7 +333,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
b := &fakeBrain{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{})
svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{})
ctx := baseCtx()
ctx.Classification = "confidential"
@@ -387,7 +352,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
au := &fakeAudit{}
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
ctx := baseCtx()
ctx.Harness = "sovereign-soil" // asserted
@@ -407,7 +372,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
// us-nexus origin is fine for non-confidential data.
b := &fakeBrain{}
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
ctx := baseCtx()
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
rec, err := svc.Capture(context.Background(), CaptureInput{
@@ -426,7 +391,7 @@ func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
b := &fakeBrain{}
tr := &fakeTracker{}
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
svc := newSvc(b, tr, nil, fakePolicy{}, au)
svc := newSvc(b, tr, fakePolicy{}, au)
_, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
@@ -443,7 +408,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
// proceeds and the receipt flags the degraded audit state.
b := &fakeBrain{}
au := &fakeAudit{reserveMode: AuditBuffered}
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au)
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
@@ -458,7 +423,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
// dry_run must not even probe the audit sink (writes nothing anywhere).
au := &fakeAudit{reserveErr: errors.New("would refuse")}
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au)
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au)
rec, err := svc.Capture(context.Background(), CaptureInput{
Context: baseCtx(),
-15
View File
@@ -55,7 +55,6 @@ type request struct {
Context contextBody `json:"context"`
Insights []insightBody `json:"insights"`
Tickets []ticketBody `json:"tickets"`
Summary *summaryBody `json:"summary,omitempty"`
DryRun bool `json:"dry_run"`
}
@@ -82,12 +81,6 @@ type ticketBody struct {
Body string `json:"body,omitempty"`
}
type summaryBody struct {
Title string `json:"title"`
Body string `json:"body"`
ReposTouched []string `json:"repos_touched,omitempty"`
}
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
// result to an HTTP status.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -183,11 +176,6 @@ func (b request) toInput() capture.CaptureInput {
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
})
}
if b.Summary != nil {
in.Summary = &capture.Summary{
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
}
}
return in
}
@@ -204,9 +192,6 @@ func statusFor(rec capture.CaptureReceipt) int {
for _, t := range rec.Tickets {
count(&ok, &fail, t.OK)
}
if rec.Summary != nil {
count(&ok, &fail, rec.Summary.OK)
}
switch {
case fail == 0:
return http.StatusOK
@@ -49,7 +49,7 @@ func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker,
t.Helper()
cfg, err := classification.Load(t.TempDir())
require.NoError(t, err)
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
svc := capture.NewService(brainstore.New(t.TempDir()), tr, cfg, audit.NewSlogSink(nil))
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
}
@@ -190,7 +190,7 @@ func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOu
func TestAuditUnavailableIs503(t *testing.T) {
cfg, err := classification.Load(t.TempDir())
require.NoError(t, err)
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, nil, cfg, refusingAudit{})
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, cfg, refusingAudit{})
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
rr := do(t, h, "Bearer "+staticTok, internalReq())
+1 -5
View File
@@ -58,17 +58,13 @@ func captureToolDescriptor() map[string]any {
},
"insights": map[string]any{"type": "array", "items": insightItem},
"tickets": map[string]any{"type": "array", "items": ticketItem},
"summary": map[string]any{"type": "object", "properties": map[string]any{
"title": str("summary title"), "body": str("summary body"),
"repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}},
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
},
}
b, _ := json.Marshal(schema)
return map[string]any{
"name": "capture",
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets, optional summary → ai-sessions. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
"inputSchema": json.RawMessage(b),
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []st
brainDir := t.TempDir()
cfg, err := classification.Load(brainDir)
require.NoError(t, err)
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, nil, cfg, audit.NewSlogSink(nil))
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
srv := mcp.NewServer(brainDir, nil, nil, nil)
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
return srv, brainDir
+8 -7
View File
@@ -42,34 +42,35 @@ These actions are **carried into the Phase 4 capture call** as `tickets[]` rathe
## Phase 4 — Capture (one uniform call)
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets, summary → ai-sessions — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
**Note:** capture no longer writes a session summary anywhere (the `summary``ai-sessions` write path was removed — it produced a frontmatter shape `ai-sessions`' own pipeline couldn't parse, silently invisible to that repo's own audit tooling; see `ai-sessions#13`). If the session's decisions/artifacts are worth a durable narrative beyond the `insights[]` this skill writes to the brain, that's a separate, explicit call — not something this skill does implicitly.
**Assemble one payload:**
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
- **`summary`** — `{title, body, repos_touched}`. Capture writes it to `ai-sessions` and stamps `fidelity` in frontmatter. Body stays reconstructable: one-paragraph summary, decisions, key artifacts, open threads.
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`, and every entry in `summary.repos_touched`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
- Declare `context.classification: "internal"` for normal homelab work.
- `summary.repos_touched`, insight `wing`s, and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged. The summary always lands in `ai-sessions` (internal), so the summary path itself never escalates.
- Insight `wing`s and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged.
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
**GATE — dry-run first, then execute.**
1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing.
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets/summary that would land) and get explicit confirmation.
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets that would land) and get explicit confirmation.
3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed.
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`gitea:file_write_branch` summary + `brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
## Phase 5 — Verdict
Deliver a final "safe to archive" verdict in the chat. Either:
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, summary path, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
- **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it.
Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
+2
View File
@@ -2,6 +2,8 @@
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
privileged cross-harness write path touching brain + Gitea + ai-sessions.
**Superseded 2026-07-27:** the ai-sessions summary write path specified below was removed post-ship
(see `specs/capture-implementation-report.md`). `capture` now touches brain + Gitea only.
**Tracks:** hyperguild #49.
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1I5), the admissibility test in
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
+7
View File
@@ -1,6 +1,13 @@
# Capture capability — implementation report (as-built)
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50#55) closed.
**Superseded 2026-07-27:** the `summary``ai-sessions` write path documented below was removed.
It wrote a frontmatter shape (`title`/`harness`/`fidelity`/`captured_at`/`repos_touched`) that
`ai-sessions`' own extract/audit pipeline couldn't parse — invisible to that repo's own audit
tooling and bypassing its redaction/completeness gates (`ai-sessions#13`). `capture` now persists
insights → brain and action items → Gitea tickets only. This document is kept as the historical
as-built record of what shipped in #49; treat every `summary`/ai-sessions reference below as
retired, not current behaviour.
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.