Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0155a2ab | ||
|
|
1cea2c9f78 | ||
|
|
9dcd60931a | ||
|
|
1fac90ed2a | ||
|
|
cb9c2513a4 | ||
|
|
3617a6c386 | ||
|
|
1938170131 | ||
|
|
b34717e6b8 | ||
|
|
d8d7e9a307 |
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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