Skip to content
Merged
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ gitcontribute mcp serve --transport=stdio

MCP capabilities are deliberately separate:

The CLI advertises the focused `contribute` toolset by default. Add specialized
surfaces only when needed: `mcp serve --toolsets=contribute,code`,
`--toolsets=contribute,research`, `--toolsets=contribute,portfolio`,
`--toolsets=contribute,advanced`, or `--toolsets=all`. Smaller catalogs reduce
overlapping choices and agent context cost; toolsets change discovery only,
not authority or side-effect annotations.
Add `--read-only` to remove every tool whose MCP annotation permits local
writes or execution. External read-only lookups remain available when their
toolset is enabled.

Tool names use the `<capability>.<action>` namespace. MCP clients qualify them
with the configured `gitcontribute` server name, producing names such as
`gitcontribute.corpus.search_repositories`. The server advertises one canonical
Expand Down Expand Up @@ -454,7 +464,6 @@ gitcontribute search issues "data race" --repo owner/repo --state open --json
gitcontribute search prs "flaky" --repo owner/repo --label bug --json
gitcontribute search threads "memory leak" --repo owner/repo
gitcontribute search code "context.WithTimeout" --repo owner/repo
gitcontribute search all "retry" --repo owner/repo

gitcontribute dossier build owner/repo
gitcontribute dossier export owner/repo --format markdown \
Expand Down Expand Up @@ -543,8 +552,15 @@ gitcontribute search issues "retry" --lens my-lens
gitcontribute lens explain my-lens issue:owner/repo#42 --query "retry"
```

Search results explain their scores. Most typed searches support opaque cursor
pagination; `search all` and lens-ranked searches do not.
Repository and thread searches use weighted SQLite FTS5 relevance and accept
`--sort=updated` in the CLI or `sort=updated` through MCP when the task is
specifically about newest matches.
Title/name and tags/topics count more than descriptions or bodies; hydrated
discussion and indexed file contents count less. Search responses are compact
excerpts, and exact reads provide full details. Most typed searches support
opaque cursor pagination; lens-ranked searches do not. Cross-kind `search all`
is intentionally absent because BM25 ranks from separate indexes are not
comparable.

</details>

Expand Down
16 changes: 16 additions & 0 deletions docs/agent-tool-evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ be supported by repeated model-backed or human-agent traces. Such evaluations
must remain optional and non-gating unless their model, prompts, credentials,
and sampling policy are made reproducible outside the unit-test suite.

## Optional model-in-the-loop suite

The paired v2 fixtures under `internal/mcpserver/testdata/agent-eval` preserve
three real failure modes: confusing relevance with newest order, treating
repository metadata as README coverage, and silently rebuilding a persisted
dossier. Give the candidate only `public-v2.json` and the seeded MCP server.
Keep `oracle-v2.json` outside its filesystem and context. A separate reviewer
scores semantic correctness, required evidence, the critical discriminator,
and uncertainty before comparing tool calls, response bytes, or latency.

Use the same model, sampling settings, corpus fixture revision, toolsets, and
read-only mode for baseline/candidate comparisons. Save initialize, tools/list,
tool calls, tool results, final answer, elapsed time, and failures. At least
three repeated runs per scenario are needed before making tool-choice claims;
the deterministic Go tests validate contracts but never count as model runs.

## Decisions from the initial baseline

The durable-job scenario requires one submission and one poll. The current
Expand Down
82 changes: 72 additions & 10 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,14 @@ Cancellation, a page error, or a stale source revision leaves the previous
complete set visible. An empty complete set is meaningful: it replaces old
children and records complete coverage with zero items.

## Durable jobs
## Persistent job records

Long-running application operations return durable job IDs. Job state and
events live in SQLite; goroutines only perform the active work.
Long-running application operations return stable job IDs. Job state and
events live in SQLite; active execution remains process-bound. A stale running
job is marked failed after restart and is never silently replayed, because
external reads and host operations cannot all promise safe automatic replay.
The agent may inspect the stored request and explicitly resubmit an idempotent
operation after reviewing the failure.

Each `JobExecutor` registers an opaque owner ID and periodically updates its
lease. A new executor reconciles only running jobs whose owner is absent or
Expand All @@ -211,9 +215,10 @@ delivered to an in-process worker directly or observed by its polling loop from
another process. Reconciliation uses an immediate SQLite transaction so a
heartbeat cannot interleave between the liveness read and stale-owner update.
MCP job reads expose structured phase, completed-item, total-item, percentage,
and retry-delay fields. Batch reads and cancellation preserve input order and
isolate per-item failures; free-form durable event text is not an MCP progress
contract.
and retry-delay fields. Concise polling omits stored request and result payloads;
detailed mode retrieves them after a finalist is terminal. Batch reads and
cancellation preserve input order and isolate per-item failures; free-form job
event text is not an MCP progress contract.

### Bounded batch operations

Expand All @@ -223,6 +228,30 @@ acquisition uses a lower ceiling because it performs Git processes and local
writes. A single unavailable or retryable item does not erase successful
siblings. Callers should retry only items marked retryable and use the provided
recovery hint for unavailable inputs.
Duplicate batch keys are rejected before submission instead of being silently
collapsed, because collapsing would make the returned outcome count differ
from the requested input count. Index requests also reject two remotes for the
same repository as ambiguous.

### Agent tool contract

MCP exposes one canonical `gitcontribute://` resource namespace. Historical
resource aliases are not advertised or routed. Tool names follow capability
boundaries (`corpus`, `github`, `code`, `workspace`, `validation`, `workflow`,
and `research`) rather than mirroring low-level API endpoints. Frequently
chained operations may be consolidated only when they share one authority and
one failure boundary; a read must never hide a refresh, write, or process run.
The CLI defaults to the focused `contribute` catalog. Code/workspace execution,
external derived research, diagnostics, portfolio, and advanced similarity are
opt-in profiles; `all` exists for auditing and embedding.

Tool inputs are strict and bounded, output distinguishes total population from
returned/truncated items, and errors state how the caller can recover. MCP SDK
annotations describe observable effects: pure external reads are read-only and
idempotent with open-world access, while reads that also persist projections
remain write operations. Catalog changes require realistic multi-call agent
evaluations, including held-out queries, tool-call count, errors, latency, and
context size; scripted schema checks alone do not establish good tool choice.

## GitHub transport

Expand Down Expand Up @@ -252,10 +281,36 @@ cleanliness and removed after indexing.
Validation is a different capability. It executes only after the caller passes
the explicit execution flag and records the command, working directory,
environment allowlist, timeout, output bound, and result.
The MCP definition tool accepts managed workspace IDs rather than arbitrary
host paths. The application resolves each ID and verifies that it belongs to
the selected investigation before persisting executable state. The explicit
CLI remains a local-user interface and may accept a directly supplied path.

## Search and analysis

Search uses the local SQLite corpus and FTS indexes. Thread search indexes
Search uses the local SQLite corpus and FTS5 indexes; agents query bounded
application tools rather than receiving raw database access. Repository ranking
weights owner/name at 10, topics at 5, and description at 2. Thread ranking
weights title at 10, labels at 5, body at 2, and complete hydrated facet evidence
at 0.5. Code ranking weights path at 5 and content at 1. README text remains
available through code search when indexed; it is not silently treated as
repository metadata because code coverage can be partial.
Scoped code search returns the selected snapshot manifest even when no document
matches, so absence can be separated from a missing or truncated index.
Snapshots created before manifests were introduced report
`indexed_coverage_unknown`; their zero skip counts are never presented as proof
of complete coverage.

Title, labels, body, and hydrated evidence are materialized into one search
document per thread and ranked by one BM25 invocation. Ranks from the legacy
thread and facet indexes are never compared; the facet index is used only to
identify the matching evidence source and excerpt.

Relevance is the default. Equal-ranked results use newest source revision as
the first tie-breaker. Repository and thread tools also expose `sort=updated`
for tasks that explicitly ask for the newest matching records. Search returns
bounded excerpts rather than complete thread bodies or files; exact-object
tools provide details after the agent narrows candidates. Thread search indexes
titles and bodies plus product-selected fields from complete hydrated issue
comments, pull-request reviews, review comments, and opt-in timeline events.
The searchable facet projection is replaced in the same transaction as its
Expand All @@ -265,9 +320,16 @@ document, and matches report the source facet plus a bounded excerpt. Untrusted
discussion remains searchable data and cannot grant capabilities. Cursors
encode their query and scope so they cannot be reused for a different search.
Ordering always has a deterministic tie-breaker.

Scores are explanations, not opaque relevance claims. They are derived from
stored matches, freshness, coverage, and optional lens weights. Lens ranking
Hydrated search text is materialized once per complete facet replacement and
bounded to 262,144 characters per thread. Results expose
`match_truncated=true` when that bound omitted text; complete API coverage must
not be mistaken for complete search-text coverage.

FTS rank is retrieval evidence and must not be relabeled as a separately
hand-written score. Match explanations report the actual lower-is-better BM25
rank and the indexed document or hydrated facet that supplied the excerpt;
they do not guess token matches with a second string matcher. Freshness and
coverage are separate facts. Lens ranking
uses a bounded population and therefore does not support cursor pagination.
Contribution Radar similarly ranks a bounded open-issue population, separates
score from the explicit `ready_to_code`, `needs_diagnosis`,
Expand Down
1 change: 1 addition & 0 deletions internal/app/acquisition.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,6 @@ func (s *Service) Acquire(ctx context.Context, repo cli.RepoRef, remote string)
Inserted: inserted,
AcquiredAt: formatTime(acq.AcquiredAt),
Message: message,
IndexManifest: snapshot.Manifest,
}, nil
}
41 changes: 10 additions & 31 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package app

import (
Expand All @@ -16,7 +16,6 @@
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/morluto/gitcontribute/internal/cli"
"github.com/morluto/gitcontribute/internal/codeindex"
"github.com/morluto/gitcontribute/internal/config"
Expand Down Expand Up @@ -505,7 +504,17 @@
if search.Total != 1 {
t.Fatalf("search total = %d, want 1", search.Total)
}
if search.Matches[0].Body != "" || search.Matches[0].MatchExcerpt == "" {
t.Fatalf("MCP search should return a compact match excerpt, got %+v", search.Matches[0])
}

_, err = reader.Dossier(ctx, mcpserver.RepoInput{Owner: "acme", Repo: "rocket"})
if !errors.Is(err, mcpserver.ErrNotFound) {
t.Fatalf("MCP dossier before build error = %v, want ErrNotFound", err)
}
if _, err := svc.BuildRepositoryDossier(ctx, cli.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil {
t.Fatalf("build dossier: %v", err)
}
dossier, err := reader.Dossier(ctx, mcpserver.RepoInput{Owner: "acme", Repo: "rocket"})
if err != nil {
t.Fatalf("mcp dossier: %v", err)
Expand Down Expand Up @@ -791,36 +800,6 @@
}
}

func TestDefineValidationParsesQuotedArguments(t *testing.T) {
ctx := context.Background()
paths := config.NewPaths(&config.Env{Home: t.TempDir()})
svc, err := New(paths, "test", nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = svc.Close() }()
if _, err := svc.Init(ctx); err != nil {
t.Fatal(err)
}
inv, err := svc.StartInvestigation(ctx, cli.RepoRef{Owner: "owner", Repo: "repo"}, "abc123", "")
if err != nil {
t.Fatal(err)
}

def, err := svc.DefineValidation(ctx, inv.ID, cli.DefineValidationOptions{
Kind: "test",
Command: `printf '%s value' ok`,
WorkingDir: t.TempDir(),
})
if err != nil {
t.Fatal(err)
}
want := []string{"printf", "%s value", "ok"}
if diff := cmp.Diff(want, def.Command); diff != "" {
t.Fatalf("command argv mismatch (-want +got):\n%s", diff)
}
}

func TestMirrorNamesAreUnambiguous(t *testing.T) {
a := mirrorNameFor("a", "b-c", "https://github.com/a/b-c.git")
b := mirrorNameFor("a-b", "c", "https://github.com/a-b/c.git")
Expand Down
2 changes: 1 addition & 1 deletion internal/app/contribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func (s *Service) workspaceDiff(ctx context.Context, workspaceID string, inv *in
!strings.EqualFold(ws.RepoName, inv.Repo.Repo) {
return "", errors.New("workspace does not belong to the opportunity investigation and repository")
}
mgr, err := s.workspaceManager(ctx)
mgr, err := s.workspaceReader()
if err != nil {
return "", err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/corpus_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func TestListCorpusInventoryCombinesSchemaRepositoriesAndProjections(t *testing.
if result.Schema == nil || result.Schema.State != "current" || len(result.Repositories) != 1 || result.Repositories[0].Repo != "owner/repo" {
t.Fatalf("inventory = %+v", result)
}
if len(result.Projections) != 3 || result.DatabaseBytes == 0 || result.SizeAttribution == "" {
if len(result.Projections) != 4 || result.DatabaseBytes == 0 || result.SizeAttribution == "" {
t.Fatalf("inventory metadata = %+v", result)
}
}
4 changes: 3 additions & 1 deletion internal/app/dossier.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
Expand All @@ -17,6 +18,7 @@ import (
const maxSeedLimit = 1000

var (
errDossierNotFound = errors.New("dossier not found")
conventionalCommitRe = regexp.MustCompile(`(?i)^\s*(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\([^)]+\))?(!)?:\s*`)
issueRefRe = regexp.MustCompile(`(?i)(?:^|\s)(?:close[ds]?|fix(?:es|ed)?|resolve[ds]?|relate[ds]?|refs?|references?)?\s*#(\d+)`)
pathLikeRe = regexp.MustCompile(`[a-zA-Z0-9_.-]+/[a-zA-Z0-9_./-]+`)
Expand Down Expand Up @@ -120,7 +122,7 @@ func (s *Service) GetRepositoryDossier(ctx context.Context, repo cli.RepoRef) (*
return nil, fmt.Errorf("get dossier: %w", err)
}
if record == nil {
return nil, fmt.Errorf("dossier not found for %s", ref)
return nil, fmt.Errorf("%w for %s", errDossierNotFound, ref)
}
return dossierFromRecord(record, sources)
}
Expand Down
17 changes: 16 additions & 1 deletion internal/app/dossier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/morluto/gitcontribute/internal/config"
"github.com/morluto/gitcontribute/internal/corpus"
"github.com/morluto/gitcontribute/internal/domain"
"github.com/morluto/gitcontribute/internal/mcpserver"
)

func TestBuildAndGetRepositoryDossier(t *testing.T) {
Expand Down Expand Up @@ -149,11 +150,25 @@ func TestBuildAndGetRepositoryDossier(t *testing.T) {
t.Fatal("expected source refs in dossier")
}

if _, err := svc.corpus.UpsertRepository(ctx, corpus.Repository{
Owner: ref.Owner, Name: ref.Repo, Description: "A changed repo", Stars: 99,
SourceUpdatedAt: time.Unix(3000, 0).UTC(),
}, `{}`); err != nil {
t.Fatalf("update repository after dossier build: %v", err)
}
mcpDossier, err := svc.MCPReader().Dossier(ctx, mcpserver.RepoInput{Owner: ref.Owner, Repo: ref.Repo})
if err != nil {
t.Fatalf("read persisted MCP dossier: %v", err)
}
if stars := mcpDossier.Sections["stars"]; stars != 10 {
t.Fatalf("MCP dossier stars = %v, want persisted value 10", stars)
}

res, err := svc.Dossier(ctx, cli.RepoRef{Owner: ref.Owner, Repo: ref.Repo})
if err != nil {
t.Fatalf("dossier summary: %v", err)
}
if res.Stars != 10 || res.OpenIssues != 1 || res.Summary != "A test repo" {
if res.Stars != 99 || res.OpenIssues != 1 || res.Summary != "A changed repo" {
t.Fatalf("unexpected dossier summary: %+v", res)
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/hydration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func TestHydrateIssueCommentsPaginatesAndRecordsCoverage(t *testing.T) {
if err != nil {
t.Fatalf("explain hydrated comment match: %v", err)
}
if !slices.ContainsFunc(explanation.Reasons, func(reason string) bool { return strings.Contains(reason, "stored issue_comments") }) {
if !slices.ContainsFunc(explanation.Reasons, func(reason string) bool { return strings.Contains(reason, "query in issue_comments") }) {
t.Fatalf("hydrated comment explanation = %+v", explanation)
}
}
Expand Down
15 changes: 15 additions & 0 deletions internal/app/investigation_thread_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/morluto/gitcontribute/internal/corpus"
"github.com/morluto/gitcontribute/internal/domain"
"github.com/morluto/gitcontribute/internal/investigation"
"github.com/morluto/gitcontribute/internal/mcpserver"
"github.com/morluto/gitcontribute/internal/research"
)

Expand Down Expand Up @@ -103,6 +104,20 @@ func TestStartInvestigationFromPullRequestUsesResolvedKind(t *testing.T) {
}
}

func TestMCPStartInvestigationFromStoredThreadCreatesBaselineHypothesis(t *testing.T) {
t.Parallel()
fixture := newResearchFixture(t)
out, err := (&MCPReader{Service: fixture.svc}).StartInvestigation(fixture.ctx, mcpserver.StartInvestigationInput{
Owner: "owner", Repo: "repo", Number: 1,
})
if err != nil {
t.Fatal(err)
}
if out.ID == "" || out.HypothesisTotal != 1 || len(out.Hypotheses) != 1 || out.Hypotheses[0].Title == "" {
t.Fatalf("atomic thread investigation = %+v", out)
}
}

func TestStartInvestigationFromThreadErrorsAndCancellation(t *testing.T) {
t.Parallel()
fixture := newResearchFixture(t)
Expand Down
7 changes: 4 additions & 3 deletions internal/app/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ func defaultJobExecutorConfig() jobExecutorConfig {
}
}

// JobExecutor submits durable jobs, runs them asynchronously, and supports
// cancellation, progress recording, and safe shutdown.
// JobExecutor persists job records, runs work asynchronously in this process,
// and supports cancellation, progress recording, and safe shutdown. It does
// not replay interrupted host or network operations after restart.
//
// Each executor registers a unique owner in the corpus and heartbeats while it
// is open. Startup only reconciles jobs whose owner is missing or has a stale
Expand Down Expand Up @@ -414,7 +415,7 @@ func (s *Service) GetJob(ctx context.Context, id string) (*cli.JobResult, error)
return &result, nil
}

// submitJob persists a durable job and runs fn asynchronously.
// submitJob persists a job record and runs fn asynchronously in this process.
func (s *Service) submitJob(ctx context.Context, kind string, request any, fn JobFunc) (string, error) {
jobs, err := s.Jobs(ctx)
if err != nil {
Expand Down
Loading
Loading