Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0155a2ab | ||
|
|
1cea2c9f78 | ||
|
|
9dcd60931a | ||
|
|
1fac90ed2a | ||
|
|
cb9c2513a4 | ||
|
|
3617a6c386 | ||
|
|
1938170131 | ||
|
|
b34717e6b8 | ||
|
|
d8d7e9a307 | ||
|
|
f0055483a3 | ||
|
|
6d014c1d0f | ||
|
|
1001acfb44 | ||
|
|
6ad275b505 | ||
|
|
b600cc986c |
@@ -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
@@ -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
|
||||
|
||||
@@ -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''']
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/tier"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||
)
|
||||
|
||||
const defaultAnthropicProbe = "https://api.anthropic.com"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/mathiasbq/supervisor
|
||||
module git.d-ma.be/mathias/hyperguild
|
||||
|
||||
go 1.26.1
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -156,17 +156,40 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
return "", fmt.Errorf("create hall dir: %w", err)
|
||||
}
|
||||
|
||||
existingFields, body := splitFrontmatter(opts.Content)
|
||||
existingByKey := make(map[string]frontmatterField, len(existingFields))
|
||||
for _, f := range existingFields {
|
||||
existingByKey[f.key] = f
|
||||
}
|
||||
emitted := make(map[string]bool, 6)
|
||||
|
||||
var fm strings.Builder
|
||||
fm.WriteString("---\n")
|
||||
fmt.Fprintf(&fm, "wing: %s\n", brain.Sanitise(opts.Wing))
|
||||
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
|
||||
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
|
||||
if opts.Type != "" {
|
||||
fmt.Fprintf(&fm, "type: %s\n", opts.Type)
|
||||
}
|
||||
if opts.Domain != "" {
|
||||
fmt.Fprintf(&fm, "domain: %s\n", opts.Domain)
|
||||
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
|
||||
|
||||
// writeField merges one key: opts.Content's own value (if the note already
|
||||
// carries this field in its own frontmatter) always wins over the fallback,
|
||||
// so promotion/extraction-step metadata survives verbatim instead of being
|
||||
// shadowed by a second, stacked frontmatter block (#86).
|
||||
writeField := func(key, fallback string) {
|
||||
emitted[key] = true
|
||||
if f, ok := existingByKey[key]; ok {
|
||||
for _, line := range f.lines {
|
||||
fm.WriteString(line)
|
||||
fm.WriteString("\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
if fallback != "" {
|
||||
fmt.Fprintf(&fm, "%s: %s\n", key, fallback)
|
||||
}
|
||||
}
|
||||
writeField("type", opts.Type)
|
||||
writeField("domain", opts.Domain)
|
||||
|
||||
sourceType := opts.SourceType
|
||||
if sourceType == "" && opts.Hall == "facts" {
|
||||
// Most hall=facts entries are first-party (an eval/benchmark the
|
||||
@@ -175,18 +198,69 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
// citation-needing entry (brain-gardener#7).
|
||||
sourceType = "internal"
|
||||
}
|
||||
if sourceType != "" {
|
||||
fmt.Fprintf(&fm, "source_type: %s\n", sourceType)
|
||||
writeField("source_type", sourceType)
|
||||
|
||||
for _, f := range existingFields {
|
||||
if emitted[f.key] {
|
||||
continue
|
||||
}
|
||||
for _, line := range f.lines {
|
||||
fm.WriteString(line)
|
||||
fm.WriteString("\n")
|
||||
}
|
||||
}
|
||||
fm.WriteString("---\n")
|
||||
|
||||
if err := os.WriteFile(dest, []byte(fm.String()+opts.Content), 0o644); err != nil {
|
||||
if err := os.WriteFile(dest, []byte(fm.String()+body), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
// frontmatterField is one top-level YAML key from a frontmatter block,
|
||||
// along with its raw line and any indented continuation lines (e.g. a
|
||||
// bulleted list value spanning multiple lines).
|
||||
type frontmatterField struct {
|
||||
key string
|
||||
lines []string
|
||||
}
|
||||
|
||||
// splitFrontmatter splits a leading "---\n...\n---\n" YAML block out of
|
||||
// content, returning its top-level fields in original order and the
|
||||
// remaining body. If content has no leading frontmatter block, fields is
|
||||
// nil and body is content unchanged.
|
||||
func splitFrontmatter(content string) (fields []frontmatterField, body string) {
|
||||
if !strings.HasPrefix(content, "---\n") {
|
||||
return nil, content
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
i := 1
|
||||
var cur *frontmatterField
|
||||
for ; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
if cur != nil {
|
||||
fields = append(fields, *cur)
|
||||
}
|
||||
key, _, _ := strings.Cut(line, ":")
|
||||
cur = &frontmatterField{key: strings.TrimSpace(key), lines: []string{line}}
|
||||
} else if cur != nil {
|
||||
cur.lines = append(cur.lines, line)
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
fields = append(fields, *cur)
|
||||
}
|
||||
body = strings.Join(lines[i:], "\n")
|
||||
return fields, body
|
||||
}
|
||||
|
||||
// writeLegacyNote preserves the original brain/knowledge/ behaviour for
|
||||
// callers that have not adopted the wing/hall taxonomy.
|
||||
func writeLegacyNote(brainDir string, opts WriteNoteOptions) (string, error) {
|
||||
|
||||
@@ -186,6 +186,48 @@ func TestWriteNote_HallRouteOmitsSourceTypeForNonFactsHalls(t *testing.T) {
|
||||
assert.NotContains(t, string(got), "source_type")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallRouteMergesExistingFrontmatterInsteadOfStacking(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "---\ntitle: act_runner host-executor\ntags: [gitea-actions, act_runner]\n---\n\n# Body\n\nSome content.\n",
|
||||
Filename: "act-runner-host-executor",
|
||||
Wing: "homelab",
|
||||
Hall: "failures",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
body := string(got)
|
||||
|
||||
// exactly one frontmatter block: only two "---" delimiter lines total
|
||||
assert.Equal(t, 2, strings.Count(body, "---\n"), "expected a single merged frontmatter block, not stacked blocks")
|
||||
assert.Contains(t, body, "wing: homelab")
|
||||
assert.Contains(t, body, "hall: failures")
|
||||
assert.Contains(t, body, "title: act_runner host-executor")
|
||||
assert.Contains(t, body, "tags: [gitea-actions, act_runner]")
|
||||
assert.Contains(t, body, "# Body")
|
||||
}
|
||||
|
||||
func TestWriteNote_HallRouteExistingTypeWinsOverOptsType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
|
||||
Content: "---\ntype: hypothesis\n---\n\nBody.\n",
|
||||
Filename: "note",
|
||||
Wing: "agentsquad",
|
||||
Hall: "decisions",
|
||||
Type: "decision", // should lose to content's own "type: hypothesis"
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(got), "type: hypothesis")
|
||||
assert.NotContains(t, string(got), "type: decision")
|
||||
}
|
||||
|
||||
func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) {
|
||||
dir, h := setup(t)
|
||||
body, _ := json.Marshal(map[string]any{"content": "auto name"})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,6 +76,15 @@ func buildFrontmatter(rp RawPage, date string) string {
|
||||
}
|
||||
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
||||
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
||||
if rp.Source != "" {
|
||||
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
||||
}
|
||||
if rp.Author != "" {
|
||||
fmt.Fprintf(&sb, "author: %s\n", yamlScalar(rp.Author))
|
||||
}
|
||||
if rp.Published != "" {
|
||||
fmt.Fprintf(&sb, "published: %s\n", yamlScalar(rp.Published))
|
||||
}
|
||||
case "concept":
|
||||
if rp.Domain != "" {
|
||||
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
||||
|
||||
@@ -154,6 +154,52 @@ func TestBuildPages_EntityNoSubtype(t *testing.T) {
|
||||
assert.Contains(t, pages[0].Content, "title: 'Basecamp'")
|
||||
}
|
||||
|
||||
func TestBuildPages_SourcePageCarriesSourceAuthorPublished(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{
|
||||
Title: "Ornith",
|
||||
Type: "source",
|
||||
Subtype: "article",
|
||||
Content: "## Summary\n\nAn agentic coding model.\n",
|
||||
Source: "https://example.com/ornith",
|
||||
Author: "Jane Doe",
|
||||
Published: "2026-07-20",
|
||||
},
|
||||
}
|
||||
pages, warnings := BuildPages(raw, "ornith", "2026-07-26")
|
||||
require.Len(t, pages, 1)
|
||||
assert.Empty(t, warnings)
|
||||
|
||||
p := pages[0]
|
||||
assert.Contains(t, p.Content, "source: 'https://example.com/ornith'")
|
||||
assert.Contains(t, p.Content, "author: 'Jane Doe'")
|
||||
assert.Contains(t, p.Content, "published: '2026-07-20'")
|
||||
}
|
||||
|
||||
func TestBuildPages_SourcePageOmitsSourceAuthorPublishedWhenEmpty(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "Shape Up", Type: "source", Subtype: "book", Content: "## Summary\n\nA book.\n"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "shape-up", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.NotContains(t, pages[0].Content, "source:")
|
||||
assert.NotContains(t, pages[0].Content, "author:")
|
||||
assert.NotContains(t, pages[0].Content, "published:")
|
||||
}
|
||||
|
||||
func TestBuildPages_ConceptPageIgnoresSourceAuthorPublished(t *testing.T) {
|
||||
// source/author/published are source-note-only metadata; a concept page
|
||||
// shouldn't carry them even if somehow set on the RawPage.
|
||||
raw := []RawPage{
|
||||
{Title: "Betting", Type: "concept", Content: "## Definition\n\nFoo.\n", Source: "x", Author: "y", Published: "z"},
|
||||
}
|
||||
pages, _ := BuildPages(raw, "src", "2026-04-23")
|
||||
require.Len(t, pages, 1)
|
||||
assert.NotContains(t, pages[0].Content, "source:")
|
||||
assert.NotContains(t, pages[0].Content, "author:")
|
||||
assert.NotContains(t, pages[0].Content, "published:")
|
||||
}
|
||||
|
||||
func TestBuildPages_EmptyTitleSkippedWithWarning(t *testing.T) {
|
||||
raw := []RawPage{
|
||||
{Title: "", Type: "concept", Content: "## Definition\n\nFoo.\n"},
|
||||
|
||||
@@ -51,6 +51,21 @@ func buildTitleMap(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entry)
|
||||
return m
|
||||
}
|
||||
|
||||
// pathStylePrefixes are known root prefixes the LLM extraction step
|
||||
// sometimes bakes into a wikilink target instead of emitting a clean
|
||||
// wing/hall/slug path (or bare title). Stripping them repairs the link
|
||||
// in place — see hyperguild#87.
|
||||
var pathStylePrefixes = []string{"wing:", "wiki/"}
|
||||
|
||||
func stripPathStylePrefix(displayName string) (string, bool) {
|
||||
for _, prefix := range pathStylePrefixes {
|
||||
if stripped, ok := strings.CutPrefix(displayName, prefix); ok {
|
||||
return stripped, true
|
||||
}
|
||||
}
|
||||
return displayName, false
|
||||
}
|
||||
|
||||
func canonicalizeContent(content string, titleToSlug map[string]string) (string, []string) {
|
||||
var warnings []string
|
||||
result := plainLinkRE.ReplaceAllStringFunc(content, func(match string) string {
|
||||
@@ -59,12 +74,17 @@ func canonicalizeContent(content string, titleToSlug map[string]string) (string,
|
||||
return match
|
||||
}
|
||||
displayName := sub[1]
|
||||
slug, ok := titleToSlug[strings.ToLower(displayName)]
|
||||
if !ok {
|
||||
warnings = append(warnings, fmt.Sprintf("unknown wikilink: [[%s]]", displayName))
|
||||
return match
|
||||
|
||||
if slug, ok := titleToSlug[strings.ToLower(displayName)]; ok {
|
||||
return "[[" + slug + "|" + displayName + "]]"
|
||||
}
|
||||
return "[[" + slug + "|" + displayName + "]]"
|
||||
|
||||
if stripped, hadPrefix := stripPathStylePrefix(displayName); hadPrefix {
|
||||
return "[[" + stripped + "]]"
|
||||
}
|
||||
|
||||
warnings = append(warnings, fmt.Sprintf("unknown wikilink: [[%s]]", displayName))
|
||||
return match
|
||||
})
|
||||
return result, warnings
|
||||
}
|
||||
|
||||
@@ -102,6 +102,53 @@ func TestCanonicalizeLinks_CurrentBatchPagesResolved(t *testing.T) {
|
||||
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_StripsWingColonPrefix(t *testing.T) {
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/homelab/failures/act-runner-host-mode-container-needs-node-and-libatomic.md",
|
||||
Content: "---\ntitle: 'act_runner host-mode'\n---\n\nSee [[wing:homelab/failures/rootless-buildah-act-runner-run-containers-denied]].\n",
|
||||
},
|
||||
}
|
||||
got, warnings := CanonicalizeLinks(pages, map[wiki.PageType][]wiki.Entry{})
|
||||
require.Len(t, got, 1)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[homelab/failures/rootless-buildah-act-runner-run-containers-denied]]")
|
||||
assert.NotContains(t, got[0].Content, "wing:")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_StripsWikiSlashPrefix(t *testing.T) {
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/agentsquad/hypotheses/council-consolidation-standalone-deliberation-service.md",
|
||||
Content: "---\ntitle: 'council consolidation'\n---\n\nSee [[wiki/agentsquad/decisions/autoresearch-council-sibling-pipe]].\n",
|
||||
},
|
||||
}
|
||||
got, warnings := CanonicalizeLinks(pages, map[wiki.PageType][]wiki.Entry{})
|
||||
require.Len(t, got, 1)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[agentsquad/decisions/autoresearch-council-sibling-pipe]]")
|
||||
assert.NotContains(t, got[0].Content, "wiki/agentsquad/decisions/autoresearch-council-sibling-pipe]]\n\n") // no leftover wiki/ prefix
|
||||
assert.NotContains(t, got[0].Content, "[[wiki/")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_TitleLookupStillTakesPriorityOverPrefixStrip(t *testing.T) {
|
||||
// A plain link that resolves via the title map must still use the
|
||||
// normal slug|Display form, not fall through to prefix-strip repair.
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
Path: "wiki/sources/shape-up.md",
|
||||
Content: "---\ntitle: 'Shape Up'\n---\n\nSee [[Betting]].\n",
|
||||
},
|
||||
}
|
||||
inventory := map[wiki.PageType][]wiki.Entry{
|
||||
wiki.PageTypeConcept: {{Slug: "betting", Title: "Betting"}},
|
||||
}
|
||||
got, warnings := CanonicalizeLinks(pages, inventory)
|
||||
require.Len(t, got, 1)
|
||||
assert.Empty(t, warnings)
|
||||
assert.Contains(t, got[0].Content, "[[betting|Betting]]")
|
||||
}
|
||||
|
||||
func TestCanonicalizeLinks_MultipleLinksInOnePage(t *testing.T) {
|
||||
pages := []wiki.Page{
|
||||
{
|
||||
|
||||
@@ -15,6 +15,14 @@ type RawPage struct {
|
||||
Subtype string `json:"subtype"` // entity: person|company|tool|model|framework|technology; source: article|pdf|book|video|note|project
|
||||
Domain string `json:"domain"`
|
||||
Content string `json:"content"` // Markdown body only — no frontmatter
|
||||
|
||||
// Source, Author, Published are deterministic passthrough from the raw
|
||||
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
||||
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
||||
// might emit. Only meaningful for Type == "source".
|
||||
Source string `json:"-"`
|
||||
Author string `json:"-"`
|
||||
Published string `json:"-"`
|
||||
}
|
||||
|
||||
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
|
||||
@@ -98,6 +106,60 @@ func repairJSON(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sourceMeta is source/author/published pulled from the raw ingested
|
||||
// content's own frontmatter — deterministic passthrough, never LLM output.
|
||||
type sourceMeta struct {
|
||||
Source string
|
||||
Author string
|
||||
Published string
|
||||
}
|
||||
|
||||
// parseContentFrontmatter extracts source/author/published from a leading
|
||||
// "---\n...\n---" YAML block in raw ingested content. Only these three flat
|
||||
// scalar keys are recognised; anything else in the block is ignored. Returns
|
||||
// a zero-value sourceMeta if content has no frontmatter block.
|
||||
func parseContentFrontmatter(content string) sourceMeta {
|
||||
var meta sourceMeta
|
||||
if !strings.HasPrefix(content, "---\n") && !strings.HasPrefix(content, "---\r\n") {
|
||||
return meta
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines[1:] {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
break
|
||||
}
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
switch key {
|
||||
case "source":
|
||||
meta.Source = val
|
||||
case "author":
|
||||
meta.Author = val
|
||||
case "published":
|
||||
meta.Published = val
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// applySourceMeta deterministically overwrites Source/Author/Published on
|
||||
// every "source"-type page with meta — the LLM never controls these fields.
|
||||
func applySourceMeta(pages []RawPage, meta sourceMeta) {
|
||||
for i := range pages {
|
||||
if pages[i].Type != "source" {
|
||||
continue
|
||||
}
|
||||
pages[i].Source = meta.Source
|
||||
pages[i].Author = meta.Author
|
||||
pages[i].Published = meta.Published
|
||||
}
|
||||
}
|
||||
|
||||
func stripFences(s string) string {
|
||||
for _, prefix := range []string{"```json\n", "```json\r\n", "```\n", "```\r\n"} {
|
||||
if strings.HasPrefix(s, prefix) {
|
||||
|
||||
@@ -59,6 +59,8 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
allWarnings = append(allWarnings, warnings...)
|
||||
}
|
||||
|
||||
applySourceMeta(allRaw, parseContentFrontmatter(content))
|
||||
|
||||
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,40 @@ func TestRun_MergesDuplicatePaths(t *testing.T) {
|
||||
assert.Contains(t, string(content), "[[Baz]]")
|
||||
}
|
||||
|
||||
func TestRun_ThreadsSourceAuthorPublishedFromContentFrontmatter(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
||||
}
|
||||
|
||||
llmResponse := mustJSON([]RawPage{{
|
||||
Title: "Ornith",
|
||||
Type: "source",
|
||||
Subtype: "article",
|
||||
Content: "## Summary\n\nAn agentic coding model.\n",
|
||||
}})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{{"message": map[string]any{"content": llmResponse}}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := Config{Complete: llm.New(srv.URL, "", "m", 30*time.Second).Complete}
|
||||
rawContent := "---\nsource: https://example.com/ornith\nauthor: Jane Doe\npublished: 2026-07-20\n---\n\nAn agentic coding model that runs on your laptop.\n"
|
||||
|
||||
result, err := Run(context.Background(), cfg, brainDir, rawContent, "ornith", false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Pages, 1)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", "ornith.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "source: 'https://example.com/ornith'")
|
||||
assert.Contains(t, string(content), "author: 'Jane Doe'")
|
||||
assert.Contains(t, string(content), "published: '2026-07-20'")
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -74,6 +74,13 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoTunnel's own fuzzy-match human-review queue, not source
|
||||
// material to extract (hyperguild#88) — same exclusion as
|
||||
// api.ListPending already applies when listing raw/ for promotion.
|
||||
if strings.HasPrefix(d.Name(), "tunnel-candidates-") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip files that have already been processed or permanently failed.
|
||||
if _, err := os.Stat(path + ".processed"); err == nil {
|
||||
return nil
|
||||
|
||||
@@ -229,3 +229,49 @@ func TestProcessDir_SkipsSubdirs(t *testing.T) {
|
||||
_, err = os.Stat(failedFile)
|
||||
assert.NoError(t, err, "failed subdir file should be untouched")
|
||||
}
|
||||
|
||||
// TestProcessDir_SkipsTunnelCandidateFiles guards against hyperguild#88:
|
||||
// AutoTunnel's own human-review queue (brain/raw/tunnel-candidates-*.md)
|
||||
// must never be re-ingested as if it were external source material — doing
|
||||
// so fed the raw "(term: X)" log entries back through the LLM extraction
|
||||
// pipeline, which then legitimately (from its own perspective) surfaced
|
||||
// [[X]] as a wikilink for any term that happened to match a real page
|
||||
// title, however generic (e.g. "mission").
|
||||
func TestProcessDir_SkipsTunnelCandidateFiles(t *testing.T) {
|
||||
brainDir := setupBrainDir(t)
|
||||
|
||||
tunnelFile := filepath.Join(brainDir, "raw", "tunnel-candidates-2026-07-19.md")
|
||||
require.NoError(t, os.WriteFile(tunnelFile, []byte(
|
||||
"# Tunnel candidates 2026-07-19\n\n- `wiki/homelab/decisions/foo.md` ↔ `wiki/telos/decisions/mission.md` (term: \"mission\")\n",
|
||||
), 0o644))
|
||||
|
||||
var completeCalls int
|
||||
completeFn := func(ctx context.Context, system, user string) (string, error) {
|
||||
completeCalls++
|
||||
raw := pipeline.RawPage{Title: "Should not be written", Type: "source", Subtype: "article", Content: "## Summary\n\nx.\n"}
|
||||
b, _ := json.Marshal([]pipeline.RawPage{raw})
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
BrainDir: brainDir,
|
||||
Interval: time.Hour, // not used; we call processDir directly
|
||||
Pipeline: pipeline.Config{
|
||||
Complete: completeFn,
|
||||
ChunkSize: 0,
|
||||
Schema: "# Schema\nThree page types.",
|
||||
},
|
||||
}
|
||||
|
||||
date := time.Now().UTC().Format("2006-01-02")
|
||||
errs := processDir(context.Background(), cfg, date)
|
||||
assert.Empty(t, errs)
|
||||
|
||||
assert.Zero(t, completeCalls, "tunnel-candidates file must never reach the LLM extraction pipeline")
|
||||
|
||||
// File must be left alone in raw/ — not moved to processed/, no marker written.
|
||||
_, err := os.Stat(tunnelFile + ".processed")
|
||||
assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not get a .processed marker")
|
||||
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", date, "tunnel-candidates-2026-07-19.md"))
|
||||
assert.True(t, os.IsNotExist(err), "tunnel-candidates file should not be copied to processed/")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/mathiasbq/supervisor/internal/auth"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/auth"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/brain"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/brain"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package config_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/config"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/config"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package config_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/config"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
iexec "github.com/mathiasbq/supervisor/internal/exec"
|
||||
iexec "git.d-ma.be/mathias/hyperguild/internal/exec"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/githubclient"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/githubclient"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/auth"
|
||||
"github.com/mathiasbq/supervisor/internal/registry"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/auth"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||
)
|
||||
|
||||
type request struct {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/mcp"
|
||||
"github.com/mathiasbq/supervisor/internal/registry"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/mcp"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/mcpclient"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/registry"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/session"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/session"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/session"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/session"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/skills/brain"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/skills/brain"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ package brain
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/registry"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||
)
|
||||
|
||||
// Config holds brain skill configuration.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/skills/org"
|
||||
"github.com/mathiasbq/supervisor/internal/tier"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/skills/org"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/registry"
|
||||
"github.com/mathiasbq/supervisor/internal/tier"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||
)
|
||||
|
||||
// TierFn returns the current tier. Injected for testability.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/session"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/session"
|
||||
)
|
||||
|
||||
type logArgs struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/skills/sessionlog"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/skills/sessionlog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ package sessionlog
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/registry"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/registry"
|
||||
)
|
||||
|
||||
// Config holds sessionlog skill configuration.
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/supervisor/internal/tier"
|
||||
"git.d-ma.be/mathias/hyperguild/internal/tier"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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` (I1–I5), the admissibility test in
|
||||
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
||||
|
||||
@@ -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` (I1–I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user