Compare commits
4
Commits
v0.13.2
...
7d1213451c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1213451c | ||
|
|
7c9293f4ed | ||
|
|
6e0155a2ab | ||
|
|
1cea2c9f78 |
@@ -78,6 +78,14 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|||||||
@@ -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''']
|
||||||
@@ -73,6 +73,14 @@ These rules apply to every task across every project, regardless of harness.
|
|||||||
- Surface the brain entries and issue numbers in the closing summary so the
|
- Surface the brain entries and issue numbers in the closing summary so the
|
||||||
trail is auditable.
|
trail is auditable.
|
||||||
|
|
||||||
|
## CAD sprint patterns
|
||||||
|
|
||||||
|
Two CAD sprint archetypes (defs: brain `wiki/homelab/decisions/continuous-agentic-development-cad-concept-2026-06-16`):
|
||||||
|
- **Inception Sprint** — birth a new repo. Oath-gated close; tag only once every clause holds. Brain: `.../inception-sprint-and-oath`.
|
||||||
|
- **Sweep Sprint** — clear an existing backlog. Parallel read-only research per issue → sequential one-issue-per-commit execution. Brain: `.../sweep-sprint-and-oath`.
|
||||||
|
|
||||||
|
Shared: decisions that are yours, not the agent's, surface as `DECISION NEEDED` (question/options/recommendation/blocks) — ask live if you're present, else comment and wait, never act past it. Live-breaking regressions get `git revert`, never forward-fixed; host-network work skips even read-only research (solo, synchronous, you present). Rule 6's closing ritual only fires once every Oath clause genuinely holds — blocked = descoped-and-tracked, never marked done.
|
||||||
|
|
||||||
## Default stack
|
## Default stack
|
||||||
|
|
||||||
| Layer | Default | Fallback | Last resort |
|
| Layer | Default | Fallback | Last resort |
|
||||||
|
|||||||
@@ -101,12 +101,48 @@ tasks:
|
|||||||
- task: lint
|
- task: lint
|
||||||
- task: test
|
- task: test
|
||||||
- task: vet
|
- task: vet
|
||||||
|
- task: security:gitleaks
|
||||||
|
|
||||||
|
# ── Security ─────────────────────────────────────────────
|
||||||
|
security:gitleaks:
|
||||||
|
desc: Scan the working tree for secrets (gitleaks, fail-closed; skipped if gitleaks absent)
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "⚠ gitleaks not installed — skipping secret scan (CI enforces it)."
|
||||||
|
echo " Install: go install github.com/zricethezav/gitleaks/v8@latest"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
"$GL" detect --no-git --redact --config .gitleaks.toml --source .
|
||||||
|
|
||||||
|
security:gitleaks:history:
|
||||||
|
desc: "One-time FULL-HISTORY secret audit (infra#39 rotation pass; not a per-push gate)"
|
||||||
|
dir: '{{.ROOT_DIR}}'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
GL="$(command -v gitleaks || true)"
|
||||||
|
[ -z "$GL" ] && [ -x "$(go env GOPATH 2>/dev/null)/bin/gitleaks" ] && GL="$(go env GOPATH)/bin/gitleaks"
|
||||||
|
if [ -z "$GL" ]; then
|
||||||
|
echo "gitleaks not installed: go install github.com/zricethezav/gitleaks/v8@latest" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
echo "Scanning FULL git history (redacted). Known historical leaks are expected"
|
||||||
|
echo "until the infra#39 rotation pass completes — triage against the rotation list."
|
||||||
|
"$GL" detect --redact --config .gitleaks.toml
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run ./...
|
- golangci-lint run ./...
|
||||||
- cd ingestion && golangci-lint run ./...
|
- cd ingestion && golangci-lint run ./...
|
||||||
|
|
||||||
|
lint:frontmatter:
|
||||||
|
desc: Report brain notes whose YAML frontmatter does not parse (BRAIN=path, FIX=1 repairs in place)
|
||||||
|
cmds:
|
||||||
|
- cd ingestion && go run ./cmd/frontmatterlint {{if .FIX}}-fix{{end}} {{.BRAIN | default "../../brain"}}
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cmds:
|
cmds:
|
||||||
- go test -race -count=1 ./...
|
- go test -race -count=1 ./...
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Command frontmatterlint reports markdown notes whose YAML frontmatter does
|
||||||
|
// not parse, and optionally repairs the ones that only need quoting.
|
||||||
|
//
|
||||||
|
// go run ./cmd/frontmatterlint ../../brain # report
|
||||||
|
// go run ./cmd/frontmatterlint -fix ../../brain # repair in place
|
||||||
|
//
|
||||||
|
// Exit codes: 0 clean (or everything repaired), 1 findings remain, 2 the scan
|
||||||
|
// itself failed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fix := flag.Bool("fix", false, "repair files that quoting can rescue, in place")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, err := frontmatter.Scan(root, *fix)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "frontmatterlint:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolved int
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.Repaired {
|
||||||
|
fmt.Printf("repaired %s\n", f.Path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unresolved++
|
||||||
|
fmt.Printf("BROKEN %s: %v\n", f.Path, f.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unresolved > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n%d file(s) with unparseable frontmatter", unresolved)
|
||||||
|
if !*fix {
|
||||||
|
fmt.Fprint(os.Stderr, " — re-run with -fix")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ frontmatter: %d file(s) repaired, none broken\n", len(findings))
|
||||||
|
}
|
||||||
@@ -457,12 +457,8 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
auditSink := buildAuditSink(ctx, brainDir, logger)
|
auditSink := buildAuditSink(ctx, brainDir, logger)
|
||||||
// The Gitea client also satisfies SummaryWriter (#66): session
|
|
||||||
// summaries are written to mathias/ai-sessions over the same API
|
|
||||||
// token. nil only if a future tracker impl lacks file writes.
|
|
||||||
summaryWriter, _ := tracker.(capture.SummaryWriter)
|
|
||||||
captureSvc := capture.NewService(
|
captureSvc := capture.NewService(
|
||||||
mcpSrv.BrainStore(), tracker, summaryWriter, classCfg, auditSink)
|
mcpSrv.BrainStore(), tracker, classCfg, auditSink)
|
||||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
resolver := capturehttp.NewOriginResolver(sovereign)
|
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Package capture is the Clean-Architecture use-case for the uniform
|
// Package capture is the Clean-Architecture use-case for the uniform
|
||||||
// capture capability (issue #49/#51): persist a finished session's
|
// capture capability (issue #49/#51): persist a finished session's
|
||||||
// valuable output — insights → brain, action items → Gitea tickets,
|
// valuable output — insights → brain, action items → Gitea tickets —
|
||||||
// optional summary → ai-sessions — with one invocation, identical core
|
// with one invocation, identical core behaviour across every harness.
|
||||||
// behaviour across every harness.
|
|
||||||
//
|
//
|
||||||
// This package is pure orchestration. It depends only on ports
|
// This package is pure orchestration. It depends only on ports
|
||||||
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
// (interfaces) and plain entities — no HTTP, no live Gitea, no embedding
|
||||||
@@ -83,19 +82,11 @@ type Ticket struct {
|
|||||||
Body string
|
Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary is an optional session summary bound for ai-sessions.
|
|
||||||
type Summary struct {
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
ReposTouched []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptureInput is the whole capture request.
|
// CaptureInput is the whole capture request.
|
||||||
type CaptureInput struct {
|
type CaptureInput struct {
|
||||||
Context CaptureContext
|
Context CaptureContext
|
||||||
Insights []Insight
|
Insights []Insight
|
||||||
Tickets []Ticket
|
Tickets []Ticket
|
||||||
Summary *Summary
|
|
||||||
DryRun bool
|
DryRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,12 +108,6 @@ type TicketResult struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummaryResult is the summary outcome in the receipt.
|
|
||||||
type SummaryResult struct {
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ItemError pins a failure to a specific request item for the partial
|
// ItemError pins a failure to a specific request item for the partial
|
||||||
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
// receipt. Item is a stable locator like "insight[1]" or "ticket[0]".
|
||||||
type ItemError struct {
|
type ItemError struct {
|
||||||
@@ -136,7 +121,6 @@ type ItemError struct {
|
|||||||
type CaptureReceipt struct {
|
type CaptureReceipt struct {
|
||||||
Insights []InsightResult `json:"insights"`
|
Insights []InsightResult `json:"insights"`
|
||||||
Tickets []TicketResult `json:"tickets"`
|
Tickets []TicketResult `json:"tickets"`
|
||||||
Summary *SummaryResult `json:"summary,omitempty"`
|
|
||||||
Errors []ItemError `json:"errors"`
|
Errors []ItemError `json:"errors"`
|
||||||
EffectiveClassification string `json:"effective_classification,omitempty"`
|
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
|
|||||||
@@ -69,11 +69,6 @@ type IssueTracker interface {
|
|||||||
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
CommentIssue(ctx context.Context, repo string, number int, body string) (IssueRef, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummaryWriter is the ai-sessions summary port.
|
|
||||||
type SummaryWriter interface {
|
|
||||||
WriteFile(ctx context.Context, repo, path, content string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClassificationPolicy derives a target's sensitivity (model C). The
|
// ClassificationPolicy derives a target's sensitivity (model C). The
|
||||||
// "stricter wins" combination of declared vs derived is use-case policy
|
// "stricter wins" combination of declared vs derived is use-case policy
|
||||||
// and lives in the service, so the port stays minimal. Satisfied by
|
// and lives in the service, so the port stays minimal. Satisfied by
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package capture
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,20 +14,17 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
brain BrainStore
|
brain BrainStore
|
||||||
issues IssueTracker
|
issues IssueTracker
|
||||||
summaries SummaryWriter
|
|
||||||
policy ClassificationPolicy
|
policy ClassificationPolicy
|
||||||
audit AuditSink
|
audit AuditSink
|
||||||
|
|
||||||
// now is the clock, injectable for deterministic summary paths and
|
// now is the clock, injectable for deterministic audit timestamps in
|
||||||
// audit timestamps in tests.
|
// tests.
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService constructs a Service from its ports. summaries may be nil
|
// NewService constructs a Service from its ports.
|
||||||
// when no summary persistence is wired; a CaptureInput with a Summary
|
func NewService(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
// then fails that item rather than panicking.
|
return &Service{brain: b, issues: tr, policy: p, audit: a, now: time.Now}
|
||||||
func NewService(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
|
||||||
return &Service{brain: b, issues: tr, summaries: sw, policy: p, audit: a, now: time.Now}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
var validActions = map[string]bool{"create": true, "close": true, "comment": true}
|
||||||
@@ -131,9 +126,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
for _, tk := range in.Tickets {
|
for _, tk := range in.Tickets {
|
||||||
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
receipt.Tickets = append(receipt.Tickets, TicketResult{Repo: tk.Repo, Action: tk.Action, Number: tk.Number, OK: true})
|
||||||
}
|
}
|
||||||
if in.Summary != nil {
|
|
||||||
receipt.Summary = &SummaryResult{Path: s.summaryPath(in.Context, in.Summary), OK: true}
|
|
||||||
}
|
|
||||||
return receipt, nil
|
return receipt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,16 +161,6 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
landed = append(landed, fmt.Sprintf("ticket:%s#%d", tk.Repo, res.Number))
|
||||||
}
|
}
|
||||||
|
|
||||||
if in.Summary != nil {
|
|
||||||
res, err := s.persistSummary(ctx, in.Context, in.Summary)
|
|
||||||
receipt.Summary = &res
|
|
||||||
if err != nil {
|
|
||||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "summary", Error: err.Error()})
|
|
||||||
} else {
|
|
||||||
landed = append(landed, "summary:"+res.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// I5: persist the request-level audit record of exactly what landed,
|
// I5: persist the request-level audit record of exactly what landed,
|
||||||
// using the outcome reserved before the writes. AuditBuffered surfaces
|
// using the outcome reserved before the writes. AuditBuffered surfaces
|
||||||
// the degraded (locally-buffered) state on the receipt.
|
// the degraded (locally-buffered) state on the receipt.
|
||||||
@@ -259,11 +241,6 @@ func (s *Service) resolveClassification(declared classification.Level, in Captur
|
|||||||
for _, tk := range in.Tickets {
|
for _, tk := range in.Tickets {
|
||||||
consider(classification.RepoTarget, tk.Repo)
|
consider(classification.RepoTarget, tk.Repo)
|
||||||
}
|
}
|
||||||
if in.Summary != nil {
|
|
||||||
for _, repo := range in.Summary.ReposTouched {
|
|
||||||
consider(classification.RepoTarget, repo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return effective, events
|
return effective, events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,60 +286,6 @@ func (s *Service) persistTicket(ctx context.Context, tk Ticket) (TicketResult, e
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) persistSummary(ctx context.Context, c CaptureContext, sum *Summary) (SummaryResult, error) {
|
|
||||||
if s.summaries == nil {
|
|
||||||
return SummaryResult{OK: false}, fmt.Errorf("no summary writer configured")
|
|
||||||
}
|
|
||||||
path := s.summaryPath(c, sum)
|
|
||||||
content := s.renderSummary(c, sum)
|
|
||||||
repo := "ai-sessions"
|
|
||||||
if err := s.summaries.WriteFile(ctx, repo, path, content); err != nil {
|
|
||||||
return SummaryResult{Path: path, OK: false}, err
|
|
||||||
}
|
|
||||||
return SummaryResult{Path: path, OK: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// summaryPath builds summaries/<harness>/<YYYY-MM>/<date>-<slug>-<ref8>.md.
|
|
||||||
// The ref8 disambiguator is derived from the session_ref (or the title
|
|
||||||
// when no ref is present) so distinct sessions never collide.
|
|
||||||
func (s *Service) summaryPath(c CaptureContext, sum *Summary) string {
|
|
||||||
t := s.now().UTC()
|
|
||||||
slug := brain.Sanitise(sum.Title)
|
|
||||||
if slug == "" {
|
|
||||||
slug = "summary"
|
|
||||||
}
|
|
||||||
seed := c.SessionRef
|
|
||||||
if seed == "" {
|
|
||||||
seed = sum.Title + sum.Body
|
|
||||||
}
|
|
||||||
sum8 := shortHash(seed)
|
|
||||||
return fmt.Sprintf("summaries/%s/%s/%s-%s-%s.md",
|
|
||||||
brain.Sanitise(c.Harness), t.Format("2006-01"), t.Format("2006-01-02"), slug, sum8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderSummary stamps fidelity + session metadata into frontmatter so the
|
|
||||||
// richer-fidelity-supersedes-thinner collision rule has the data it needs.
|
|
||||||
func (s *Service) renderSummary(c CaptureContext, sum *Summary) string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("---\n")
|
|
||||||
fmt.Fprintf(&b, "title: %s\n", sum.Title)
|
|
||||||
fmt.Fprintf(&b, "harness: %s\n", c.Harness)
|
|
||||||
if c.SessionRef != "" {
|
|
||||||
fmt.Fprintf(&b, "session_ref: %s\n", c.SessionRef)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "fidelity: %s\n", c.Fidelity)
|
|
||||||
fmt.Fprintf(&b, "captured_at: %s\n", s.now().UTC().Format(time.RFC3339))
|
|
||||||
if len(sum.ReposTouched) > 0 {
|
|
||||||
fmt.Fprintf(&b, "repos_touched: [%s]\n", strings.Join(sum.ReposTouched, ", "))
|
|
||||||
}
|
|
||||||
b.WriteString("---\n\n")
|
|
||||||
b.WriteString(sum.Body)
|
|
||||||
if !strings.HasSuffix(sum.Body, "\n") {
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func kindString(k classification.TargetKind) string {
|
func kindString(k classification.TargetKind) string {
|
||||||
if k == classification.RepoTarget {
|
if k == classification.RepoTarget {
|
||||||
return "repo"
|
return "repo"
|
||||||
@@ -381,8 +304,3 @@ func firstLine(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortHash(s string) string {
|
|
||||||
sum := sha256.Sum256([]byte(s))
|
|
||||||
return hex.EncodeToString(sum[:])[:8]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -85,21 +85,6 @@ func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _
|
|||||||
return IssueRef{Repo: repo, Number: number}, nil
|
return IssueRef{Repo: repo, Number: number}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeSummary struct {
|
|
||||||
paths []string
|
|
||||||
content []string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeSummary) WriteFile(_ context.Context, _, path, content string) error {
|
|
||||||
if f.err != nil {
|
|
||||||
return f.err
|
|
||||||
}
|
|
||||||
f.paths = append(f.paths, path)
|
|
||||||
f.content = append(f.content, content)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakePolicy derives from an explicit map; default Internal so tests pin
|
// fakePolicy derives from an explicit map; default Internal so tests pin
|
||||||
// behaviour without depending on the real defaulting.
|
// behaviour without depending on the real defaulting.
|
||||||
type fakePolicy struct{ tags map[string]classification.Level }
|
type fakePolicy struct{ tags map[string]classification.Level }
|
||||||
@@ -135,8 +120,8 @@ func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) erro
|
|||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func newSvc(b BrainStore, tr IssueTracker, sw SummaryWriter, p ClassificationPolicy, a AuditSink) *Service {
|
func newSvc(b BrainStore, tr IssueTracker, p ClassificationPolicy, a AuditSink) *Service {
|
||||||
s := NewService(b, tr, sw, p, a)
|
s := NewService(b, tr, p, a)
|
||||||
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
s.now = func() time.Time { return time.Date(2026, 6, 22, 12, 0, 0, 0, time.UTC) }
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -151,7 +136,7 @@ func TestCaptureHappyPath(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -180,7 +165,7 @@ func TestCaptureHappyPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
func TestCaptureSupersedeNotDuplicate(t *testing.T) {
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -197,7 +182,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -216,7 +201,7 @@ func TestCaptureValidationFailClosed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
func TestCaptureValidationRejectsBadTicket(t *testing.T) {
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
Tickets: []Ticket{{Repo: "hyperguild", Action: "frobnicate"}}, // bad action
|
||||||
@@ -239,7 +224,7 @@ func TestCapturePartialFailureBestEffort(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -264,7 +249,7 @@ func TestCaptureDryRunWritesNothing(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -287,7 +272,7 @@ func TestCaptureStricterClassificationWins(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(b, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "internal"
|
ctx.Classification = "internal"
|
||||||
@@ -306,7 +291,7 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
|||||||
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
// Caller declares confidential; target internal → effective confidential, NOT a security event.
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
pol := fakePolicy{tags: map[string]classification.Level{"hyperguild": classification.Internal}}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -319,26 +304,6 @@ func TestCaptureCallerRaisingSensitivityHonoured(t *testing.T) {
|
|||||||
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
assert.Empty(t, au.entries[0].SecurityEvents, "raising sensitivity is honoured, not flagged")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCaptureSummaryPathAndFidelity(t *testing.T) {
|
|
||||||
sw := &fakeSummary{}
|
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, sw, fakePolicy{}, &fakeAudit{})
|
|
||||||
|
|
||||||
ctx := baseCtx()
|
|
||||||
ctx.Fidelity = "transcript-parse"
|
|
||||||
ctx.SessionRef = "abc123def456"
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
|
||||||
Context: ctx,
|
|
||||||
Summary: &Summary{Title: "Session Wrap", Body: "did stuff", ReposTouched: []string{"hyperguild"}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, rec.Summary)
|
|
||||||
assert.True(t, rec.Summary.OK)
|
|
||||||
require.Len(t, sw.paths, 1)
|
|
||||||
assert.True(t, strings.HasPrefix(sw.paths[0], "summaries/claude-code/2026-06/"), "path: %s", sw.paths[0])
|
|
||||||
assert.Contains(t, sw.paths[0], "session-wrap")
|
|
||||||
assert.Contains(t, sw.content[0], "fidelity: transcript-parse", "fidelity stamped in frontmatter")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- I1 sovereignty gate (#53) ---
|
// --- I1 sovereignty gate (#53) ---
|
||||||
|
|
||||||
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
||||||
@@ -346,7 +311,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
|||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, tr, nil, pol, au)
|
svc := newSvc(b, tr, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -368,7 +333,7 @@ func TestCaptureRefusesConfidentialViaUSNexus(t *testing.T) {
|
|||||||
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
func TestCaptureAllowsConfidentialViaSovereign(t *testing.T) {
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, pol, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, pol, &fakeAudit{})
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Classification = "confidential"
|
ctx.Classification = "confidential"
|
||||||
@@ -387,7 +352,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
|||||||
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
// us-nexus; confidential ⇒ refused, and the discrepancy is a security event.
|
||||||
au := &fakeAudit{}
|
au := &fakeAudit{}
|
||||||
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
pol := fakePolicy{tags: map[string]classification.Level{"client-seb": classification.Confidential}}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, pol, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, pol, au)
|
||||||
|
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Harness = "sovereign-soil" // asserted
|
ctx.Harness = "sovereign-soil" // asserted
|
||||||
@@ -407,7 +372,7 @@ func TestCaptureAssertedLabelIgnoredAndLogged(t *testing.T) {
|
|||||||
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||||
// us-nexus origin is fine for non-confidential data.
|
// us-nexus origin is fine for non-confidential data.
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, &fakeAudit{})
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, &fakeAudit{})
|
||||||
ctx := baseCtx()
|
ctx := baseCtx()
|
||||||
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
ctx.Origin = ZoneUSNexus // internal classification, so gate doesn't fire
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
@@ -426,7 +391,7 @@ func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
|||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
tr := &fakeTracker{}
|
tr := &fakeTracker{}
|
||||||
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
svc := newSvc(b, tr, fakePolicy{}, au)
|
||||||
|
|
||||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -443,7 +408,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
|||||||
// proceeds and the receipt flags the degraded audit state.
|
// proceeds and the receipt flags the degraded audit state.
|
||||||
b := &fakeBrain{}
|
b := &fakeBrain{}
|
||||||
au := &fakeAudit{reserveMode: AuditBuffered}
|
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au)
|
svc := newSvc(b, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
@@ -458,7 +423,7 @@ func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
|||||||
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||||
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||||
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au)
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, fakePolicy{}, au)
|
||||||
|
|
||||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
Context: baseCtx(),
|
Context: baseCtx(),
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ type request struct {
|
|||||||
Context contextBody `json:"context"`
|
Context contextBody `json:"context"`
|
||||||
Insights []insightBody `json:"insights"`
|
Insights []insightBody `json:"insights"`
|
||||||
Tickets []ticketBody `json:"tickets"`
|
Tickets []ticketBody `json:"tickets"`
|
||||||
Summary *summaryBody `json:"summary,omitempty"`
|
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +81,6 @@ type ticketBody struct {
|
|||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type summaryBody struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
ReposTouched []string `json:"repos_touched,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||||
// result to an HTTP status.
|
// result to an HTTP status.
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -183,11 +176,6 @@ func (b request) toInput() capture.CaptureInput {
|
|||||||
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
Repo: t.Repo, Action: t.Action, Number: t.Number, Title: t.Title, Body: t.Body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if b.Summary != nil {
|
|
||||||
in.Summary = &capture.Summary{
|
|
||||||
Title: b.Summary.Title, Body: b.Summary.Body, ReposTouched: b.Summary.ReposTouched,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return in
|
return in
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +192,6 @@ func statusFor(rec capture.CaptureReceipt) int {
|
|||||||
for _, t := range rec.Tickets {
|
for _, t := range rec.Tickets {
|
||||||
count(&ok, &fail, t.OK)
|
count(&ok, &fail, t.OK)
|
||||||
}
|
}
|
||||||
if rec.Summary != nil {
|
|
||||||
count(&ok, &fail, rec.Summary.OK)
|
|
||||||
}
|
|
||||||
switch {
|
switch {
|
||||||
case fail == 0:
|
case fail == 0:
|
||||||
return http.StatusOK
|
return http.StatusOK
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func newHandler(t *testing.T, v capturehttp.Validator, tr capture.IssueTracker,
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
cfg, err := classification.Load(t.TempDir())
|
cfg, err := classification.Load(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(t.TempDir()), tr, nil, cfg, audit.NewSlogSink(nil))
|
svc := capture.NewService(brainstore.New(t.TempDir()), tr, cfg, audit.NewSlogSink(nil))
|
||||||
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
return capturehttp.New(svc, v, staticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ func (refusingAudit) Record(context.Context, capture.AuditEntry, capture.AuditOu
|
|||||||
func TestAuditUnavailableIs503(t *testing.T) {
|
func TestAuditUnavailableIs503(t *testing.T) {
|
||||||
cfg, err := classification.Load(t.TempDir())
|
cfg, err := classification.Load(t.TempDir())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, nil, cfg, refusingAudit{})
|
svc := capture.NewService(brainstore.New(t.TempDir()), fakeTracker{}, cfg, refusingAudit{})
|
||||||
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
h := capturehttp.New(svc, nil, staticTok, "local-cli", capturehttp.NewOriginResolver(nil))
|
||||||
|
|
||||||
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
rr := do(t, h, "Bearer "+staticTok, internalReq())
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Package frontmatter lints and repairs the YAML frontmatter block of a
|
||||||
|
// markdown note.
|
||||||
|
//
|
||||||
|
// Notes written directly — hand-authored, or promoted from brain/raw/ — do not
|
||||||
|
// pass through pipeline.BuildPages, so nothing quotes their scalars. A title
|
||||||
|
// containing an unescaped ": " then produces a block that yaml.Unmarshal
|
||||||
|
// rejects with "mapping values are not allowed here", and every consumer that
|
||||||
|
// reads frontmatter silently skips the note.
|
||||||
|
//
|
||||||
|
// Fix is deliberately conservative: it only adds quoting, never reorders or
|
||||||
|
// rewrites, and it refuses to return a document that still does not parse.
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fence = "---"
|
||||||
|
|
||||||
|
// Lint reports whether doc's frontmatter block parses as YAML.
|
||||||
|
//
|
||||||
|
// A document with no frontmatter block, or with an unterminated one, is not an
|
||||||
|
// error: there is nothing claiming to be YAML.
|
||||||
|
func Lint(doc []byte) error {
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(b, &v); err != nil {
|
||||||
|
return fmt.Errorf("frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix quotes unquoted scalars whose value breaks YAML parsing and returns the
|
||||||
|
// repaired document.
|
||||||
|
//
|
||||||
|
// It returns (doc, false, nil) when the document already parses, and
|
||||||
|
// (doc, false, err) when quoting cannot rescue it — an unfixable document is
|
||||||
|
// always returned byte-identical, never half-repaired.
|
||||||
|
func Fix(doc []byte) ([]byte, bool, error) {
|
||||||
|
if err := Lint(doc); err == nil {
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b, ok := block(doc)
|
||||||
|
if !ok {
|
||||||
|
// Lint only fails when there is a block, so this is unreachable in
|
||||||
|
// practice; return the original rather than guess.
|
||||||
|
return doc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed := quoteScalars(b)
|
||||||
|
if bytes.Equal(fixed, b) {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: not repairable by quoting: %w", Lint(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
var v any
|
||||||
|
if err := yaml.Unmarshal(fixed, &v); err != nil {
|
||||||
|
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rest, _ := split(doc)
|
||||||
|
out := []byte(fence + "\n")
|
||||||
|
out = append(out, fixed...)
|
||||||
|
out = append(out, []byte(fence)...)
|
||||||
|
out = append(out, rest...)
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mappingLine matches "key: value" at any indentation, capturing the prefix so
|
||||||
|
// it can be re-emitted untouched.
|
||||||
|
//
|
||||||
|
// Sequence items ("- value") are deliberately not handled: "- foo: bar" is
|
||||||
|
// valid YAML, so a document containing one does not reach Fix in the first
|
||||||
|
// place. See the Lint table for why that gap is left open.
|
||||||
|
var mappingLine = regexp.MustCompile(`^(\s*[A-Za-z_][\w.-]*:[ \t]+)(\S.*)$`)
|
||||||
|
|
||||||
|
// quoteScalars single-quotes every unquoted scalar value in the block that
|
||||||
|
// contains a colon. Values that are already quoted, or that carry YAML syntax
|
||||||
|
// this package has no business rewriting, are left alone.
|
||||||
|
func quoteScalars(block []byte) []byte {
|
||||||
|
lines := strings.Split(string(block), "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
m := mappingLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix, value := m[1], m[2]
|
||||||
|
if !needsQuoting(value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines[i] = prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsQuoting reports whether a scalar value must be quoted to parse as the
|
||||||
|
// string it plainly is. Only the colon case is handled: it is the one this
|
||||||
|
// package exists for, and every other special case risks rewriting a value
|
||||||
|
// that was deliberately structured (a nested mapping, an anchor, a number).
|
||||||
|
func needsQuoting(value string) bool {
|
||||||
|
if strings.HasPrefix(value, "'") || strings.HasPrefix(value, `"`) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "|") || strings.HasPrefix(value, ">") {
|
||||||
|
return false // block scalar; its content is not parsed as YAML
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") {
|
||||||
|
return false // flow collection; quoting would silently make it a string
|
||||||
|
}
|
||||||
|
return strings.Contains(value, ": ") || strings.HasSuffix(value, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
// block returns the raw YAML between the opening and closing fences.
|
||||||
|
func block(doc []byte) ([]byte, bool) {
|
||||||
|
b, _, ok := split(doc)
|
||||||
|
return b, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// split returns the frontmatter block and everything from the closing fence
|
||||||
|
// onward (the closing fence itself excluded, its trailing newline included).
|
||||||
|
func split(doc []byte) (block, rest []byte, ok bool) {
|
||||||
|
s := string(doc)
|
||||||
|
if !strings.HasPrefix(s, fence+"\n") {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
body := s[len(fence)+1:]
|
||||||
|
|
||||||
|
idx := strings.Index(body, "\n"+fence)
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// ingestion/internal/frontmatter/frontmatter_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no frontmatter block is not an error",
|
||||||
|
doc: "# Just a heading\n\nSome body text.\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted title parses",
|
||||||
|
doc: "---\ntitle: 'Headless dispatch runs: Bash is denied'\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain title parses",
|
||||||
|
doc: "---\ntitle: A simple title\nwing: homelab\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted colon in title does not parse",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Known gap, deliberately not fixed here: this parses, but the
|
||||||
|
// item becomes map[string]any{"Headless dispatch": "denied"}
|
||||||
|
// rather than a string. It is a silent type corruption, not a
|
||||||
|
// parse failure, so repairing it would rewrite documents that
|
||||||
|
// currently load — out of scope for a lint about broken YAML.
|
||||||
|
name: "unquoted colon in a list item parses as a mapping, not an error",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated frontmatter is not treated as frontmatter",
|
||||||
|
doc: "---\ntitle: no closing fence\n\nbody\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := Lint([]byte(tt.doc))
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
doc string
|
||||||
|
wantChanged bool
|
||||||
|
wantKey string
|
||||||
|
wantValue string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "quotes an unquoted title containing a colon",
|
||||||
|
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Headless dispatch runs: Bash is denied — use gitea MCP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Parses already (see the Lint table), so Fix must leave it alone
|
||||||
|
// rather than rewrite a document that loads.
|
||||||
|
name: "leaves a colon-bearing list item alone because it already parses",
|
||||||
|
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escapes an embedded single quote when quoting",
|
||||||
|
doc: "---\ntitle: Don't do this: it breaks\n---\n\nbody\n",
|
||||||
|
wantChanged: true,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Don't do this: it breaks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves an already-valid document untouched",
|
||||||
|
doc: "---\ntitle: 'Already quoted: fine'\nwing: homelab\n---\n\nbody\n",
|
||||||
|
wantChanged: false,
|
||||||
|
wantKey: "title",
|
||||||
|
wantValue: "Already quoted: fine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leaves a document with no frontmatter untouched",
|
||||||
|
doc: "# Heading\n\nbody: with a colon\n",
|
||||||
|
wantChanged: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, changed, err := Fix([]byte(tt.doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantChanged, changed)
|
||||||
|
|
||||||
|
if !tt.wantChanged {
|
||||||
|
assert.Equal(t, tt.doc, string(got), "unchanged docs must be returned byte-identical")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever came back must parse — that is the whole point.
|
||||||
|
require.NoError(t, Lint(got), "Fix must not return a document that still fails to parse")
|
||||||
|
|
||||||
|
if tt.wantKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block, ok := block(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
var m map[string]any
|
||||||
|
require.NoError(t, yaml.Unmarshal(block, &m))
|
||||||
|
assert.Equal(t, tt.wantValue, m[tt.wantKey], "the value must survive quoting unchanged")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_PreservesBodyExactly(t *testing.T) {
|
||||||
|
body := "\nThe body has: colons, 'quotes', and\n---\na horizontal rule.\n"
|
||||||
|
doc := "---\ntitle: Broken: title\n---" + body
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
assert.True(t, len(got) > 0)
|
||||||
|
|
||||||
|
_, rest, ok := split([]byte(doc))
|
||||||
|
require.True(t, ok)
|
||||||
|
_, gotRest, ok := split(got)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
|
||||||
|
// A frontmatter block that quoting cannot rescue: the structure itself is
|
||||||
|
// broken, not a scalar. Fix must return the original and an error rather
|
||||||
|
// than a half-repaired document.
|
||||||
|
doc := "---\ntitle: fine\n\tbad: tab-indented mapping\n - stray list item\n---\n\nbody\n"
|
||||||
|
|
||||||
|
got, changed, err := Fix([]byte(doc))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, changed)
|
||||||
|
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding is one markdown file whose frontmatter does not parse.
|
||||||
|
type Finding struct {
|
||||||
|
Path string // relative to the scanned root
|
||||||
|
Err error // why it does not parse, or why the repair was refused
|
||||||
|
Repaired bool // true only when fix was requested and the file was rewritten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan walks root for *.md files whose frontmatter fails to parse.
|
||||||
|
//
|
||||||
|
// With fix set, each finding that quoting can rescue is rewritten in place;
|
||||||
|
// anything quoting cannot rescue is reported and left byte-identical. A file
|
||||||
|
// that cannot be read or decoded is reported as a finding rather than aborting
|
||||||
|
// the walk — one poisoned note must not hide the other findings.
|
||||||
|
func Scan(root string, fix bool) ([]Finding, error) {
|
||||||
|
var found []Finding
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, relErr := filepath.Rel(root, path)
|
||||||
|
if relErr != nil {
|
||||||
|
rel = path
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: readErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !utf8.Valid(doc) {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fmt.Errorf("not valid UTF-8")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if lintErr := Lint(doc); lintErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fix {
|
||||||
|
found = append(found, Finding{Path: rel, Err: Lint(doc)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired, changed, fixErr := Fix(doc)
|
||||||
|
if fixErr != nil || !changed {
|
||||||
|
found = append(found, Finding{Path: rel, Err: fixErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if writeErr := writeFilePreservingMode(path, repaired); writeErr != nil {
|
||||||
|
found = append(found, Finding{Path: rel, Err: writeErr})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found = append(found, Finding{Path: rel, Repaired: true})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan %s: %w", root, err)
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFilePreservingMode(path string, content []byte) error {
|
||||||
|
mode := fs.FileMode(0o644)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, content, mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// ingestion/internal/frontmatter/scan_test.go
|
||||||
|
package frontmatter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTree(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(root, name)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
brokenDoc = "---\ntitle: Broken: title here\nwing: homelab\n---\n\nbody\n"
|
||||||
|
validDoc = "---\ntitle: 'Fine: quoted'\n---\n\nbody\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScan_ReportsOnlyBrokenFiles(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
"wiki/b/plain.md": "# no frontmatter\n\nbody: with colon\n",
|
||||||
|
"wiki/a/notes.txt": brokenDoc, // not markdown — must be ignored
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/a/broken.md", found[0].Path)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
// Dry run must not touch the file.
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, brokenDoc, string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_FixWritesRepairedFilesAndLeavesTheRestAlone(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{
|
||||||
|
"wiki/a/broken.md": brokenDoc,
|
||||||
|
"wiki/a/valid.md": validDoc,
|
||||||
|
})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.True(t, found[0].Repaired)
|
||||||
|
assert.NoError(t, found[0].Err)
|
||||||
|
|
||||||
|
repaired, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, Lint(repaired), "the repaired file must parse")
|
||||||
|
assert.Contains(t, string(repaired), "title: 'Broken: title here'")
|
||||||
|
assert.Contains(t, string(repaired), "wing: homelab", "untouched keys must survive")
|
||||||
|
|
||||||
|
untouched, err := os.ReadFile(filepath.Join(root, "wiki/a/valid.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, validDoc, string(untouched))
|
||||||
|
|
||||||
|
// Second pass finds nothing — the repair is idempotent and complete.
|
||||||
|
again, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUnfixableWithoutWriting(t *testing.T) {
|
||||||
|
unfixable := "---\ntitle: fine\n\tbad: tab-indented\n - stray\n---\n\nbody\n"
|
||||||
|
root := writeTree(t, map[string]string{"wiki/x.md": unfixable})
|
||||||
|
|
||||||
|
found, err := Scan(root, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.False(t, found[0].Repaired)
|
||||||
|
assert.Error(t, found[0].Err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(root, "wiki/x.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, unfixable, string(got), "an unfixable file must be left exactly as it was")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScan_ReportsUndecodableFilesInsteadOfFailingTheRun(t *testing.T) {
|
||||||
|
root := writeTree(t, map[string]string{"wiki/ok.md": validDoc})
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "wiki/bad-bytes.md"),
|
||||||
|
[]byte{'-', '-', '-', '\n', 0xff, 0xfe, '\n', '-', '-', '-', '\n'}, 0o644))
|
||||||
|
|
||||||
|
found, err := Scan(root, false)
|
||||||
|
require.NoError(t, err, "one unreadable file must not abort the whole scan")
|
||||||
|
require.Len(t, found, 1)
|
||||||
|
assert.Equal(t, "wiki/bad-bytes.md", found[0].Path)
|
||||||
|
}
|
||||||
@@ -58,17 +58,13 @@ func captureToolDescriptor() map[string]any {
|
|||||||
},
|
},
|
||||||
"insights": map[string]any{"type": "array", "items": insightItem},
|
"insights": map[string]any{"type": "array", "items": insightItem},
|
||||||
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
||||||
"summary": map[string]any{"type": "object", "properties": map[string]any{
|
|
||||||
"title": str("summary title"), "body": str("summary body"),
|
|
||||||
"repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
||||||
}},
|
|
||||||
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(schema)
|
b, _ := json.Marshal(schema)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"name": "capture",
|
"name": "capture",
|
||||||
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets, optional summary → ai-sessions. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
||||||
"inputSchema": json.RawMessage(b),
|
"inputSchema": json.RawMessage(b),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []st
|
|||||||
brainDir := t.TempDir()
|
brainDir := t.TempDir()
|
||||||
cfg, err := classification.Load(brainDir)
|
cfg, err := classification.Load(brainDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, nil, cfg, audit.NewSlogSink(nil))
|
svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
|
||||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign))
|
||||||
return srv, brainDir
|
return srv, brainDir
|
||||||
|
|||||||
@@ -42,34 +42,35 @@ These actions are **carried into the Phase 4 capture call** as `tickets[]` rathe
|
|||||||
|
|
||||||
## Phase 4 — Capture (one uniform call)
|
## Phase 4 — Capture (one uniform call)
|
||||||
|
|
||||||
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets, summary → ai-sessions — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
|
Persist the session via a **single `capture` call** (the `brain:capture` MCP tool, live on the Claude.ai connector). Capture owns the writes server-side — insights → brain, action items → Gitea tickets — plus the I1 sovereignty gate, the I5 audit record, and the supersession/read-after-write discipline. The skill's job is to *assemble the payload*, not to write each store itself. Do NOT fall back to separate `gitea:file_write_branch` + `brain_write` steps unless `capture` is unreachable (see fallback below).
|
||||||
|
|
||||||
|
**Note:** capture no longer writes a session summary anywhere (the `summary` → `ai-sessions` write path was removed — it produced a frontmatter shape `ai-sessions`' own pipeline couldn't parse, silently invisible to that repo's own audit tooling; see `ai-sessions#13`). If the session's decisions/artifacts are worth a durable narrative beyond the `insights[]` this skill writes to the brain, that's a separate, explicit call — not something this skill does implicitly.
|
||||||
|
|
||||||
**Assemble one payload:**
|
**Assemble one payload:**
|
||||||
|
|
||||||
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
|
- **`insights[]`** — the generalizable learnings from Phase 1 (decisions/failures worth re-reading). Each: `{text, wing, hall}`; add `supersede_slug` to revise a prior note in place instead of creating a duplicate. `hall` ∈ facts/decisions/failures/hypotheses/sources.
|
||||||
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
|
- **`tickets[]`** — the issue actions from Phase 3: `{repo, action, ...}` where action ∈ create/close/comment. Owner is always `mathias` (server-forced).
|
||||||
- **`summary`** — `{title, body, repos_touched}`. Capture writes it to `ai-sessions` and stamps `fidelity` in frontmatter. Body stays reconstructable: one-paragraph summary, decisions, key artifacts, open threads.
|
|
||||||
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
|
- **`context`** — `{harness: "claudeai-chat", session_ref: <chatid8-or-slug>, fidelity: "live-capture", actor: "mathias", classification: <see gate below>}`.
|
||||||
|
|
||||||
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
|
**THE CLASSIFICATION GATE (read before calling — this is where capture refuses).**
|
||||||
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`, and every entry in `summary.repos_touched`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
|
Capture computes an **effective classification = the strictest across EVERY target it touches** (each insight's `wing`, each ticket's `repo`), then refuses if that effective level is `confidential` and the origin is us-nexus (claude.ai is us-nexus). Levels come from `classification.yaml` at the brain root (source of truth, #67), with the code defaults as the floor: `hyperguild`/`homelab` → internal; `client-*` → confidential; **anything untagged → confidential (fail-safe)**.
|
||||||
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
|
- **Tagged `internal` today** (safe through claude.ai): wings `hyperguild`, `homelab`; repos `brain`, `ai-sessions`, `infra`, `hyperguild`, `homelab`, `tapir`, `agentsquad`, `jepa-fx-risk`, `swedsl`. Treat `classification.yaml` as authoritative — this list is a hint, not gospel.
|
||||||
- Declare `context.classification: "internal"` for normal homelab work.
|
- Declare `context.classification: "internal"` for normal homelab work.
|
||||||
- `summary.repos_touched`, insight `wing`s, and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged. The summary always lands in `ai-sessions` (internal), so the summary path itself never escalates.
|
- Insight `wing`s and ticket `repo`s are classification INPUTS, not free-form metadata — every target must resolve `internal` or the whole capture escalates to `confidential` and the gate refuses via claude.ai. Listing the central homelab repos (incl. `brain`/`ai-sessions`) is now fine; they're tagged.
|
||||||
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
|
- If a session genuinely touched **`client-*` or otherwise-untagged** material, it cannot be captured through claude.ai — note that in the verdict rather than trying to force it.
|
||||||
|
|
||||||
**GATE — dry-run first, then execute.**
|
**GATE — dry-run first, then execute.**
|
||||||
1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing.
|
1. Call `capture` with `dry_run: true`. It validates the whole payload and returns the would-be receipt + `effective_classification`, writing nothing.
|
||||||
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets/summary that would land) and get explicit confirmation.
|
2. **STOP. Show the dry-run receipt** (effective classification, the insights/tickets that would land) and get explicit confirmation.
|
||||||
3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed.
|
3. On confirmation, call `capture` again with `dry_run: false`. Read the returned receipt: it is partial-aware (`errors[]`, per-item `ok`). Report exactly what landed.
|
||||||
|
|
||||||
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`gitea:file_write_branch` summary + `brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
|
If `capture` is **unreachable** (tool not on the connector — e.g. a session that started before a deploy; a tool-list refresh usually fixes it): say so. Only then fall back to the legacy inline path (`brain_write`/`brain_update` + `brain_get` confirm), and note in the verdict that the I5 audit record was NOT produced.
|
||||||
|
|
||||||
## Phase 5 — Verdict
|
## Phase 5 — Verdict
|
||||||
|
|
||||||
Deliver a final "safe to archive" verdict in the chat. Either:
|
Deliver a final "safe to archive" verdict in the chat. Either:
|
||||||
|
|
||||||
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, summary path, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
- **SAFE TO ARCHIVE** — list what landed from the capture receipt (issues closed/filed with numbers, brain note ids/paths) so the trail is auditable. Then list anything still in the user's queue (e.g. a PR awaiting their merge, a decision owed next session).
|
||||||
- **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it.
|
- **NOT YET** — name the specific gate that wasn't passed, the capture refusal reason, or the per-item error from the receipt, and what to do about it.
|
||||||
|
|
||||||
Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
Never claim safe-to-archive if the capture refused, any receipt item errored, or a gated confirmation was declined. The verdict is the skill's contract: if it says safe, the session can be lost without losing the work.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
|
**Status:** Decisions resolved 2026-06-22 (§4). Ready for implementation scoping. `capture` is a
|
||||||
privileged cross-harness write path touching brain + Gitea + ai-sessions.
|
privileged cross-harness write path touching brain + Gitea + ai-sessions.
|
||||||
|
**Superseded 2026-07-27:** the ai-sessions summary write path specified below was removed post-ship
|
||||||
|
(see `specs/capture-implementation-report.md`). `capture` now touches brain + Gitea only.
|
||||||
**Tracks:** hyperguild #49.
|
**Tracks:** hyperguild #49.
|
||||||
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in
|
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5), the admissibility test in
|
||||||
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
`00-synthesis-model.md`, and the distributed-consolidation shape mandated by
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
# Capture capability — implementation report (as-built)
|
# Capture capability — implementation report (as-built)
|
||||||
|
|
||||||
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50–#55) closed.
|
**Status:** Shipped 2026-06-23, tagged `v0.11.0`. Epic hyperguild #49 (sub-issues #50–#55) closed.
|
||||||
|
**Superseded 2026-07-27:** the `summary` → `ai-sessions` write path documented below was removed.
|
||||||
|
It wrote a frontmatter shape (`title`/`harness`/`fidelity`/`captured_at`/`repos_touched`) that
|
||||||
|
`ai-sessions`' own extract/audit pipeline couldn't parse — invisible to that repo's own audit
|
||||||
|
tooling and bypassing its redaction/completeness gates (`ai-sessions#13`). `capture` now persists
|
||||||
|
insights → brain and action items → Gitea tickets only. This document is kept as the historical
|
||||||
|
as-built record of what shipped in #49; treat every `summary`/ai-sessions reference below as
|
||||||
|
retired, not current behaviour.
|
||||||
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
|
**Spec:** `specs/capture-bdd-spec.md` (the design contract this implements).
|
||||||
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.
|
**Governed by:** `infra/docs/architecture/01-invariants.md` (I1–I5) + the I2 acceptance ledger entry in `infra/docs/security-baseline.md`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user