Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ El campo `instructions` del servidor dice lo mismo, y Zed no lo lee.
| la pregunta | la tool |
| --- | --- |
| quién llama a esto, qué referencia a esto | `find_references` |
| quién implementa un tipo o método | `find_implementations` |
| qué se rompe si lo cambio | `get_blast_radius` |
| qué alcanza esto hacia fuera | `trace_dependencies` |
| quién lo usa desde otro repositorio | `find_cross_repo_consumers` |
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ checkout, which is what a reader on a fork or without a network still has.
| the question | the tool |
| --- | --- |
| who calls this, what references this | `find_references` |
| who implements a type or method | `find_implementations` |
| what breaks if I change it | `get_blast_radius` |
| what does this reach outward | `trace_dependencies` |
| who uses it from another repository | `find_cross_repo_consumers` |
Expand All @@ -43,10 +44,11 @@ checkout, which is what a reader on a fork or without a network still has.
| give me the code of these symbols | `get_source` |
| everything about this one symbol | `get_symbol` |
| what is indexed, and is the graph current | `list_repositories`, `graph_status` |
| how is an asynchronous index progressing | `get_index_status` |

Eleven read-only tools, plus one consent-gated mutation (`index_project`) that a
client has to authorize before it can register a repository or publish a
generation.
Thirteen read-only tools, plus two consent-gated mutations (`index_project` and
`start_index_project`) that a client has to authorize before either can register
a repository or publish a generation.

Every row that names a symbol carries its repository, path, qualified name and
line range, so it can be opened without a second call, and every tool accepts
Expand Down Expand Up @@ -81,8 +83,8 @@ backlog and the acceptance gate of every phase are in [`TASKS.md`](TASKS.md).
- **Semantic dependencies:** Python and Dart imports can publish a package
dependency when exactly one registered provider owns the requested package;
symbol-level cross-repository edges require an explicit provider identity.
- **Surface:** eleven read-only tools over STDIO, plus one consent-gated
mutation (`index_project`). The contract is
- **Surface:** thirteen read-only tools over STDIO, plus two consent-gated
mutations (`index_project` and `start_index_project`). The contract is
[docs/protocol/mcp-surface-v3.md](docs/protocol/mcp-surface-v3.md).
- **Storage:** LadybugDB is canonical; queries are served from an immutable
HotSnapshot published atomically, never from the database.
Expand Down
8 changes: 5 additions & 3 deletions benchmarks/mcp-token-cost/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ func canonicalCommand(cfg config) string {
// internal/mcp guards the same surface in bytes, because that package has no
// tokenizer; this is the figure the reports and the protocol document quote, and
// it is the half a byte guard cannot see -- a description rewritten into fewer,
// longer words moves one number and not the other. Measured at 716 over
// generation 000206, guarded with the headroom of one description.
const maximumResidentSurfaceTokens = 800
// longer words moves one number and not the other. Measured at 812 after
// find_implementations joined the configured surface, guarded with the
// headroom of one description.
const maximumResidentSurfaceTokens = 850

func run(ctx context.Context, cfg config, command string) error {
tokens, err := newCounter()
Expand Down Expand Up @@ -253,6 +254,7 @@ func probeTools(ctx context.Context, session *sdkmcp.ClientSession, questions qu
{"find_symbol", map[string]any{"name": root}},
{"find_by_intent", map[string]any{"intent": "publish a generation"}},
{"find_references", map[string]any{"name": root}},
{"find_implementations", map[string]any{"name": root}},
{"trace_dependencies", map[string]any{"qualified_name": root, "depth": 2}},
{"get_blast_radius", map[string]any{"qualified_name": root, "depth": 2}},
} {
Expand Down
1 change: 1 addition & 0 deletions cmd/kivgraph/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ go_test(
"complete_test.go",
"configure_test.go",
"daemon_test.go",
"doctor_profile_test.go",
"endpoint_test.go",
"events_test.go",
"hook_test.go",
Expand Down
80 changes: 80 additions & 0 deletions cmd/kivgraph/doctor_profile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/Luqueee/kivgraph/internal/config"
"github.com/Luqueee/kivgraph/internal/testsupport"
)

func TestDoctorReadsServedProfileAndPreservesLegacyState(t *testing.T) {
home := t.TempDir()
testsupport.SetHome(t, home)
configPath := filepath.Join(home, "config.yaml")
if _, err := config.Initialize(config.InitOptions{ConfigPath: configPath}); err != nil {
t.Fatal(err)
}
legacy, err := config.Load(configPath)
if err != nil {
t.Fatal(err)
}
root := filepath.Dir(legacy.Config.Storage.DatabasePath)
profile, err := config.LoadProfile(configPath, "")
if err != nil {
t.Fatal(err)
}
// This abandoned legacy marker must never override the current profile.
marker := filepath.Join(root, "CURRENT")
if err := os.WriteFile(marker, []byte("invalid-legacy-generation\n"), 0o600); err != nil {
t.Fatal(err)
}
var out, diagnostic bytes.Buffer
if code := runDoctor([]string{"--config", configPath}, &out, &diagnostic); code != 0 {
t.Fatalf("doctor=%d\n%s\n%s", code, out.String(), diagnostic.String())
}
profileRoot := filepath.Dir(profile.Config.Storage.DatabasePath)
if !strings.Contains(out.String(), profileRoot) {
t.Fatalf("doctor did not report profile root %q:\n%s", profileRoot, out.String())
}
if !strings.Contains(out.String(), "graph.store: PASS (no published generation)") {
t.Fatalf("doctor did not report an unpublished graph.store:\n%s", out.String())
}
if body, err := os.ReadFile(marker); err != nil || string(body) != "invalid-legacy-generation\n" {
t.Fatalf("legacy state changed: %q %v", body, err)
}
}

func TestDoctorReportsIncompleteProfilesAndContinues(t *testing.T) {
home := t.TempDir()
testsupport.SetHome(t, home)
configPath := filepath.Join(home, "config.yaml")
if _, err := config.Initialize(config.InitOptions{ConfigPath: configPath}); err != nil {
t.Fatal(err)
}
loaded, err := config.Load(configPath)
if err != nil {
t.Fatal(err)
}
profiles := filepath.Join(filepath.Dir(loaded.Config.Storage.DatabasePath), "profiles")
if err := os.RemoveAll(profiles); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(profiles, 0o700); err != nil {
t.Fatal(err)
}

var out, diagnostic bytes.Buffer
if code := runDoctor([]string{"--config", configPath}, &out, &diagnostic); code != 1 {
t.Fatalf("doctor=%d, want 1\n%s\n%s", code, out.String(), diagnostic.String())
}
if !strings.Contains(out.String(), "config: PASS") || !strings.Contains(out.String(), "profiles: FAIL") {
t.Fatalf("doctor did not preserve config checks and report profile failure:\n%s", out.String())
}
if !strings.Contains(out.String(), "state.database_parent:") {
t.Fatalf("doctor stopped before installation checks:\n%s", out.String())
}
}
25 changes: 24 additions & 1 deletion cmd/kivgraph/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2063,6 +2063,26 @@ func doctorFlagSet(options *doctorOptions) *flag.FlagSet {
return flags
}

// Doctor follows the same default profile as serve, without initiating a
// migration during diagnosis. A legacy installation still has its old layout.
func readDoctorConfiguration(configPath string) (loadedConfiguration config.Loaded, profileErr, configurationErr error) {
loadedConfiguration, configurationErr = config.Load(configPath)
if configurationErr != nil {
return config.Loaded{}, nil, configurationErr
}
profiles := filepath.Join(filepath.Dir(loadedConfiguration.Config.Storage.DatabasePath), "profiles")
if _, err := os.Stat(profiles); errors.Is(err, os.ErrNotExist) {
return loadedConfiguration, nil, nil
} else if err != nil {
return config.Loaded{}, nil, fmt.Errorf("inspect profiles: %w", err)
}
profile, profileErr := config.ReadProfile(configPath, "")
if profileErr != nil {
return loadedConfiguration, profileErr, nil
}
return profile, nil, nil
}

func runDoctor(args []string, stdout, stderr io.Writer) int {
var options doctorOptions
flags := doctorFlagSet(&options)
Expand All @@ -2074,7 +2094,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int {
return 2
}

loaded, err := config.Load(options.ConfigPath)
loaded, profileErr, err := readDoctorConfiguration(options.ConfigPath)
if err != nil {
writeResult(stdout, false, "config: FAIL (%v)", err)
writeResult(stdout, false, "doctor: FAIL")
Expand All @@ -2092,6 +2112,9 @@ func runDoctor(args []string, stdout, stderr io.Writer) int {
}
}
doctorResult("config", true, fmt.Sprintf("schema=%d", loaded.Config.Version))
if profileErr != nil {
doctorResult("profiles", false, profileErr.Error())
}
// A retired key is not a defect in the store and not a reason to fail: the
// file was valid when it was written and the key never did anything. Saying
// so is what lets someone delete it; silence would leave it there forever.
Expand Down
51 changes: 51 additions & 0 deletions docs/adr/0115-profile-upgrade-preserves-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR 0115: preserve runtime identity during profile upgrade

## Context

The first profile migration copied the entire installation state directory.
A real `daemon.sock` made that copy fail before MCP initialization. Moving the
root would also replace live lock inodes. The profile artifact list omitted the
published freshness attestations.

These are upgrade defects, not reasons to rebuild an unchanged graph or weaken
freshness guarantees. This decision refines the migration
section of ADR 0087; it does not change the MCP envelope or analyzer guarantees.

## Decision

Keep the installation root in place. Copy only graph artifacts into a private
staging directory: generations, `CURRENT`, `BACKUP`, backups, freshness,
synthetic `go.work` and `go.work.sum`, and the legacy `graph.lbdb`. Copy the
repository registry separately. The content-addressed fact cache, analyzer
targets, logs, endpoints, PID files, sockets, and lock files remain at
installation scope and are never relocated.

A nonblocking migration lock outside the root serializes upgrades. Acquire the
existing analyzer-targets, resync, and publication locks before copying. Refuse
a reachable daemon and ask the operator to stop it. A stale Unix socket is
allowed but neither copied nor deleted. Other special top-level files, and
special files anywhere inside graph artifacts, fail closed. Permissions and
I/O errors are not interpreted as a stopped daemon or silently skipped.

Validate the registry and CURRENT target before publication. Publish a separate
`.pre-profiles` backup and then atomically rename the candidate into its profile
directory. The original graph remains untouched at installation scope as an
additional recovery source; removing it is an explicit maintenance decision,
not part of loading configuration. This temporarily costs additional disk.

If interrupted between the two renames, retry only when the backup and current
candidate have identical paths, modes and bytes. A differing backup is retained
and reported, never overwritten. Continue supporting recovery of a missing root
from the previous migration's backup. An existing profile must pass validation;
directory existence alone is not successful migration.

## Validation and limits

Regression tests use real Unix sockets and writer locks, interrupted migration
fixtures, mismatched backups, and partial destinations.

The migration checks filesystem layout, not native graph integrity; opening the
published snapshot remains a separate startup gate. Installation must stop old
writers, retain the old bundle, verify native startup, and roll back on failure.
An old binary must not resume writing the retained legacy graph alongside a new
profile-aware server.
59 changes: 59 additions & 0 deletions docs/adr/0116-typed-implementation-queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# ADR 0116: Typed implementation queries and coverage

Status: accepted for local implementation, 2026-09-05.

## Decision

Expose `find_implementations` as a read-only, single-text MCP query. It reads
`IMPLEMENTS` and `OVERRIDES` edges from one immutable published generation per
selected profile. It does not reinterpret calls or name matches as
implementations. Go retains its existing `go/types` proofs. TypeScript emits
declared and structural proofs at index time through the installed native
compiler's `isTypeAssignableTo`.

The worker examines concrete class instance types, interfaces, abstract types,
object type aliases, and concrete type instances observed in type references,
heritage clauses and construction. It never substitutes `any` for unknown type
parameters. Required member names are a conservative prefilter; the compiler
makes the final decision. Native calls are serial, and property/assignability
caches are confined to a project generation. Tests compare this selection to
an exhaustive compiler evaluation.

Interface method/property declarations now use the same classifier as provider
source lookup. Inherited implementation methods point to the actual declaring
symbol. External type identities reuse resolved imports. External method
identities require the provider's own project and canonical source declaration;
missing source identity is recorded as a coverage limitation.

## Wire and storage

`ts-facts-v5` adds `implementations` and `implementationLimitations`. Its relation
rows reuse the proven local/provider target identity shape. The Go decoder also
reads v4 for historical fixtures, with an explicit missing-analysis scope.
Provenance codes `30` and `31` append `TYPESCRIPT_IMPL_DECLARED` and
`TYPESCRIPT_IMPL_STRUCTURAL`; existing codes and stable-key derivation do not
change. Canonical schema `5` requires a full rebuild and attests the new analysis
pass. The previous database and matching executable remain the rollback pair.

Query pages carry canonical locations, provenance, detection, confidence,
generation, total, cursor and completeness. Cursors bind the query, filters,
profiles and generation. Result path filters run before counting/pagination.
Multi-profile queries retain independently indexed scopes. Legacy generations
return `LOWER_BOUND`, including when empty. New generations also return
`LOWER_BOUND` for recorded unresolved scopes across the analyzed corpus. An empty
`COMPLETE` result establishes absence only within that corpus and type-instance
universe. Inferred files currently contribute symbols/references and explicitly
do not attest implementation coverage. Compiler-error declarations and unknown
provider method identities cannot become exact edges.

Atenea binds its existing `symbol.implementations` contract to this tool,
preserves local `locations`, and adds optional evidence and pagination fields.
Atenea's maintenance coordinator owns rebuilding; a normal query cannot inherit
an indexing timeout.

## Validation and activation

Negative type fixtures, declared/structural/method/generic fixtures, actual v5
worker output, canonical normalization, scoped pagination and generation/filter
cursor rejection are required. The complete native Ladybug gate and a real
local full rebuild must pass before activation is considered validated.
42 changes: 29 additions & 13 deletions docs/protocol/mcp-surface-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ contrato observable: lo que un agente recibe y en qué puede confiar.
Las cifras que aparecen aquí las mide `benchmarks/mcp-token-cost`, con su digest
y su generación. Ninguna se declara a mano.

## 1. Las once tools
## 1. Las doce tools

```text
list_repositories find_symbol find_by_intent
get_symbol get_file_outline find_references
find_cross_repo_consumers trace_dependencies
get_blast_radius get_source graph_status
find_implementations
```

Una `serve` configurada añade tres controles de indexado:
Expand Down Expand Up @@ -53,7 +54,7 @@ CLI.
**Sin generación publicada no hay superficie.** El servidor completa el
handshake, publica cero tools de consulta y pone el comando de reconstrucción en
`instructions`. Un cliente lanza este proceso él mismo, así que salir se lee como
una caída; y publicar once tools que contestan `INDEX_NOT_READY` a todo enseña al
una caída; y publicar doce tools que contestan `INDEX_NOT_READY` a todo enseña al
agente que las tools no funcionan.

**Salvo que se pida lo contrario.** `kivgraph serve --introspection` publica el
Expand All @@ -64,9 +65,9 @@ registro o la herramienta de desarrollo que sólo puede leer lo que devuelve

Lo que la opción cambia es qué se **lista**, y nada más. No crea un índice, no
fabrica un grafo vacío, no relaja ninguna comprobación de espacio en disco y no
toca la puerta de consentimiento de las dos mutaciones. Las once tools de
consulta del grafo que expone
siguen contestando `INDEX_NOT_READY` hasta que haya generación -- `graph_status`
toca la puerta de consentimiento de las dos mutaciones. Las doce tools de
consulta del grafo que expone siguen contestando `INDEX_NOT_READY` hasta que
haya generación -- `graph_status`
es la excepción de siempre, porque la tool que explica por qué las demás se
niegan no puede negarse ella-- y el handshake sigue llevando las instrucciones
de reparación: decirle al cliente que hay grafo cuando no lo hay sería la única
Expand Down Expand Up @@ -269,24 +270,39 @@ anuncia y no se rellena describe una respuesta que no se envía.
**Lo que un anfitrión mantiene residente no es el esquema.** Oh My Pi monta cada
tool como un dispositivo cuya documentación se lee bajo demanda; Claude Code
difiere los esquemas detrás de su búsqueda de tools e inyecta `instructions` al
abrir la sesión. Lo residente es el nombre, dos veces, y la descripción: `716`
tokens -- `220` de enrutado y `496` de descripciones-- para las once de consulta
más `index_project`, medido por el arnés sobre la generación `000206`, frente a
`2.104` de esquema diferido.
abrir la sesión. Lo residente es el nombre, dos veces, y la descripción: `812`
tokens -- `279` de enrutado y `533` de descripciones-- para las doce de consulta
más los tres controles de indexado, medido por el smoke del arnés tras incorporar
`find_implementations`, frente a `5.049` de esquema diferido.

Ahí es donde vive el enrutado, y por eso cada descripción dice contra qué
alternativa nativa compite y **dónde pierde**. Nada de eso puede llevar un número
derivado del grafo: reescribiría bytes del prompt de sistema de un cliente en cada
reindexado e invalidaría su caché.

`TestServerSurfaceStaysCheapToLoad` fija el techo del esquema en `8.000`
caracteres y falla si una tool vuelve a publicar `outputSchema`;
`TestServerSurfaceStaysCheapToKeepResident` fija el residente en `1.900` bytes y
falla si una descripción contiene un dígito.
La superficie residente de las doce tools mide `1.874` bytes con la fórmula del
test, bajo un techo de `1.900` bytes. Se reproduce con
`go test ./internal/mcp -run TestServerSurfaceStaysCheap`, sobre el catálogo
estático del ejecutable y sin depender de un corpus ni una generación.
`TestServerSurfaceStaysCheapToLoad` fija el techo del esquema en `18.000` bytes y
falla si una tool vuelve a publicar `outputSchema`;
`TestServerSurfaceStaysCheapToKeepResident` guarda el techo residente y falla si
una descripción contiene un dígito.

## 9. Códigos de error

Los de PLAN.md 17.5, sin cambios. `get_file_outline` usa
`REPOSITORY_NOT_FOUND` para un repositorio que no está en el grafo y
`SYMBOL_NOT_FOUND` para una ruta que no existe bajo él: una página vacía se
leería como «aquí no hay nada declarado», que es una respuesta distinta.

## Implementaciones tipadas

`find_implementations` consulta relaciones `IMPLEMENTS` y `OVERRIDES`, con evidencia
declarada o estructural de TypeScript. Devuelve `results.subject` e
`results.implementations`, identidades canónicas, generación, cursor y
completitud. Los filtros `repo`, `language`, `paths` y `detection` se aplican
antes de paginar. Una generación anterior al esquema `5` devuelve `LOWER_BOUND`,
y una del esquema `5` también lo devuelve cuando quedan ámbitos sin resolver
registrados. Un `COMPLETE` vacío sólo demuestra ausencia dentro de ese corpus.
Véase ADR `0116` para el ámbito de tipos y la compatibilidad del protocolo.
Loading
Loading