From 1cea2c9f788f4288a0621fc3c15b76554b3005a9 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 27 Jul 2026 23:17:41 +0200 Subject: [PATCH] feat(capture): remove summary->ai-sessions write path ai-sessions#13: capture's `summary` field wrote a frontmatter shape (title/harness/fidelity/captured_at/repos_touched) that ai-sessions' own extract/audit pipeline can't parse -- silently invisible to that repo's own audit tooling, and bypassing its redaction + Stage2 completeness gates by construction. Only 5 files ever landed this way over 3 weeks; the summary capability's whole value (speed) fights ai-sessions' whole value (redacted, audited, complete), so kill it rather than build a second parse branch. capture now persists insights -> brain and action items -> Gitea tickets only. Removes Summary/SummaryResult/SummaryWriter and all wiring (service, REST body, MCP tool schema); close-session updated to stop assembling a summary payload. specs/capture-*.md kept as historical record with a superseded note -- the feature shipped and is documented, just no longer current behaviour. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WPdSHbp9Utb2hPFm9wDG59 --- ingestion/cmd/server/main.go | 6 +- ingestion/internal/capture/entities.go | 20 +--- ingestion/internal/capture/ports.go | 5 - ingestion/internal/capture/service.go | 100 ++---------------- ingestion/internal/capture/service_test.go | 69 +++--------- ingestion/internal/capturehttp/handler.go | 15 --- .../internal/capturehttp/handler_test.go | 4 +- ingestion/internal/mcp/tools_capture.go | 6 +- ingestion/internal/mcp/tools_capture_test.go | 2 +- skills/close-session/SKILL.md | 15 +-- specs/capture-bdd-spec.md | 2 + specs/capture-implementation-report.md | 7 ++ 12 files changed, 50 insertions(+), 201 deletions(-) diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 9a3b2b7..53baf1d 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -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) diff --git a/ingestion/internal/capture/entities.go b/ingestion/internal/capture/entities.go index 1c33583..0b159f8 100644 --- a/ingestion/internal/capture/entities.go +++ b/ingestion/internal/capture/entities.go @@ -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"` diff --git a/ingestion/internal/capture/ports.go b/ingestion/internal/capture/ports.go index 843f17e..92595fb 100644 --- a/ingestion/internal/capture/ports.go +++ b/ingestion/internal/capture/ports.go @@ -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 diff --git a/ingestion/internal/capture/service.go b/ingestion/internal/capture/service.go index 840ac27..bec050b 100644 --- a/ingestion/internal/capture/service.go +++ b/ingestion/internal/capture/service.go @@ -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///--.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] -} diff --git a/ingestion/internal/capture/service_test.go b/ingestion/internal/capture/service_test.go index 38b16f7..60b072e 100644 --- a/ingestion/internal/capture/service_test.go +++ b/ingestion/internal/capture/service_test.go @@ -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(), diff --git a/ingestion/internal/capturehttp/handler.go b/ingestion/internal/capturehttp/handler.go index fd765f4..15e928d 100644 --- a/ingestion/internal/capturehttp/handler.go +++ b/ingestion/internal/capturehttp/handler.go @@ -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 diff --git a/ingestion/internal/capturehttp/handler_test.go b/ingestion/internal/capturehttp/handler_test.go index e33342c..7402301 100644 --- a/ingestion/internal/capturehttp/handler_test.go +++ b/ingestion/internal/capturehttp/handler_test.go @@ -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()) diff --git a/ingestion/internal/mcp/tools_capture.go b/ingestion/internal/mcp/tools_capture.go index 771babb..f92db2f 100644 --- a/ingestion/internal/mcp/tools_capture.go +++ b/ingestion/internal/mcp/tools_capture.go @@ -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), } } diff --git a/ingestion/internal/mcp/tools_capture_test.go b/ingestion/internal/mcp/tools_capture_test.go index 5154d3d..0b72a4d 100644 --- a/ingestion/internal/mcp/tools_capture_test.go +++ b/ingestion/internal/mcp/tools_capture_test.go @@ -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 diff --git a/skills/close-session/SKILL.md b/skills/close-session/SKILL.md index 790de57..4d4f2dc 100644 --- a/skills/close-session/SKILL.md +++ b/skills/close-session/SKILL.md @@ -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: , fidelity: "live-capture", actor: "mathias", classification: }`. **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. diff --git a/specs/capture-bdd-spec.md b/specs/capture-bdd-spec.md index a5e67e9..2149c35 100644 --- a/specs/capture-bdd-spec.md +++ b/specs/capture-bdd-spec.md @@ -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 diff --git a/specs/capture-implementation-report.md b/specs/capture-implementation-report.md index f05eea0..367ef9c 100644 --- a/specs/capture-implementation-report.md +++ b/specs/capture-implementation-report.md @@ -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`.