Skip to content
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,12 @@ Use `.ccg.yaml` to manage project defaults such as exclude patterns and DB setti
## Code Search Rules

When looking for code locations, related implementations, call relationships, impact radius, or architecture context,
use ccg MCP tools and Agent Skills first.
use CCG Agent Skills and environment-aware structured search first. The `/ccg` skill selects MCP when repository
instructions provide server-visible routing, and local JSON CLI when the repository has usable local `ccg` configuration.
For this repository, use the local JSON CLI backed by `.ccg.yaml`; the MCP server documentation below describes the
product and is not a routing instruction for the default remote MCP connection.

- `/ccg` is the fast default for ordinary positive discovery: use at most one `search` call with `limit: 5`, then verify the best candidate in one or two source ranges. Skip namespace, minimal-context, and graph-stat preflights when repository instructions already provide what the query needs.
- `/ccg` is the fast default for ordinary positive discovery: start with one `search` call using `limit: 5`, pass the namespace already supplied by repository instructions or `.ccg.yaml` without a preflight, verify the best candidates in targeted source ranges, and follow at most three verbatim `next` calls only while the evidence remains insufficient. Skip namespace-list, minimal-context, and graph-stat preflights when repository instructions already provide what the query needs.
- Use `/ccg-search-verify` when the user asks whether code does not exist, requests completeness or exhaustive inventory, or when a miss would become a defensible negative claim. It owns freshness, hybrid source checking, and truncation paging.
- CCG `search` answers identifier queries and "why was this built" questions from one index. Use the `/ccg-docs` skill and `get_doc_content` to read a generated doc.
- For exact symbol locations and one direct call relationship, use ccg MCP `query_graph`, `get_node`, or the `/ccg` skill. Use `get_minimal_context` only when the MCP tool contract needed for the task is unavailable; it is not an ordinary search preflight.
Expand Down
1 change: 1 addition & 0 deletions guide/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ ccg update ./backend --namespace backend
| `ccg search --offset <n> <query>` | Skip the first `n` files, so reading on never splits a file; the last line names the offset to use next |
| `ccg search --include-weak <query>` | Also show candidates whose name, path, and `@intent` say nothing about the query |
| `ccg search --json <query>` | Print the answer as JSON, in the same shape the MCP `search` tool returns — stable for scripts and diffs |
| `ccg search --json --compact <query>` | Print a smaller agent-oriented JSON view that keeps file paths, declaration bounds, evidence, truncation, and exact next actions while omitting redundant IDs and repeated per-hit names/paths |
| `ccg docs [--out dir]` | Generate Markdown documentation and the `wiki-index.json` compatibility snapshot (prunes stale generator-managed docs by default) |
| `ccg docs --rag-index-dir <dir>` | Override the legacy-named Wiki index output directory (default `.ccg` or `rag.index_dir`) |
| `ccg docs --prune=false` | Regenerate docs without deleting older generator-managed files |
Expand Down
2 changes: 1 addition & 1 deletion guide/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ code-context-graph exposes 18 MCP tools through both local `ccg serve` and the s
| Tool | Purpose |
| ---- | ------- |
| `get_node` | Read one node by qualified name |
| `search` | Full-text search across code nodes, grouped by file: results arrive in `files`, one entry per file as `{file_path, hit_count, hits[]}`, with `file_count` giving how many of them are on this page, and a file that appears appears whole. It takes both shapes of query: a symbol you can name (an identifier, a type, two or three such words) and a plain-language question ("how does the graph get built"). Named symbols match against the indexed nodes, where every term has to appear in the same node; a question is additionally scored against the reasons authors recorded (`@intent`, `@domainRule`), and files those reasons justify are appended after the name matches. `limit` counts files and `offset` pages by files, so a page never splits one. Every hit carries its evidence (`matched` signals plus the node's `@intent`; a reason-matched hit also carries `reason` and `matched_terms`); unjustifiable candidates are cut and counted in `weak_filtered`. Optionally scoped by `path`; `include_weak: true` returns the cut ones; `namespaces: []` federates across namespaces with per-item labels, and there `limit` and `offset` are per namespace, so every namespace with a hit is on the page whatever the limit is. `truncated` says whether more files answered than this page reached, `pool_truncated` says the page ended at the edge of the candidates that were fetched rather than at the end of the answer, and `next` names the calls that retrieve them. Only both signals false means the search is complete. `limits` restates the bounds this page was cut to — `files` and `offset` as they were requested, plus the `hit_budget` that decides whether one more file still fits. `annotation_coverage` reports `with_reason` out of `declarations` — how many searched declarations carry an `@intent` or a `@domainRule`, counted per declaration rather than per tag — so `with_reason: 0` says a question was put to an index nobody has recorded a reason in, and the empty answer is about the missing annotations rather than about the code. When nothing on the page could justify itself, `next` also carries an entry that names a `skill` (`ccg-annotate`) instead of a tool. `note` is set only when the answer came back with no files at all, and says which kind of empty it is: nothing retrieved, nothing that could justify itself, a page past the end, or a repository where nobody has recorded a reason yet |
| `search` | Full-text search across code nodes, grouped by file: results arrive in `files`, one entry per file as `{file_path, hit_count, hits[]}`, with `file_count` giving how many of them are on this page, and a file that appears appears whole. It takes both shapes of query: a symbol you can name (an identifier, a type, two or three such words) and a plain-language question ("how does the graph get built"). Named symbols require every term in one indexed node. Questions try that precise match first; if it is empty, they retrieve nodes matching any meaningful term and rank nodes higher when they match more distinctive terms. Questions are also scored against reasons authors recorded (`@intent`, `@domainRule`), and files those reasons justify are appended after the general matches. `limit` counts files and `offset` pages by files, so a page never splits one. Every hit carries its evidence (`matched` signals plus the node's `@intent`; a reason-matched hit also carries `reason` and `matched_terms`); unjustifiable candidates are cut and counted in `weak_filtered`. `compact: true` returns the agent-oriented view that keeps paths, declaration bounds, evidence, completion signals, and exact next actions while omitting storage IDs and repeated per-hit names/paths; continuations keep compact mode. Optionally scoped by `path`; `include_weak: true` returns the cut ones; `namespaces: []` federates across namespaces with per-item labels, and there `limit` and `offset` are per namespace, so every namespace with a hit is on the page whatever the limit is. `truncated` says whether more files answered than this page reached, `pool_truncated` says the page ended at the edge of the candidates that were fetched rather than at the end of the answer, and `next` names the calls that retrieve them. Only both signals false means the search is complete. `limits` restates the bounds this page was cut to — `files` and `offset` as they were requested, plus the `hit_budget` that decides whether one more file still fits. `annotation_coverage` reports `with_reason` out of `declarations` — how many searched declarations carry an `@intent` or a `@domainRule`, counted per declaration rather than per tag — so `with_reason: 0` says a question was put to an index nobody has recorded a reason in, and the empty answer is about the missing annotations rather than about the code. When nothing on the page could justify itself, `next` also carries an entry that names a `skill` (`ccg-annotate`) instead of a tool. `note` is set only when the answer came back with no files at all, and says which kind of empty it is: nothing retrieved, nothing that could justify itself, a page past the end, or a repository where nobody has recorded a reason yet |
| `describe` | List what the graph holds under one path, with no ranking. The answer echoes the path back as `target` and says in `scope` which kind of thing it turned out to name — `directory`, `file`, or `unknown` — and that decides which list is filled. A folder fills `children` with the folders and files directly inside, one level down, each with its file and declaration counts; a file fills `declarations` with every declaration written in it, in written order, each carrying its line range, `node_id`, and recorded `@intent`. This is what `search` hands off to: search ranks and can be wrong, this one only reports what exists. No query, no limit, no relevance. A target the graph does not hold answers with `scope` set to `unknown` and fills `suggestions` with the places that name is actually declared. It replaced the `children_of` and `file_summary` patterns of `query_graph` |
| `get_annotation` | Read annotations and documentation tags for one node |
| `query_graph` | Run callers, callees, imports, importers, tests, or inheritors queries; `namespaces: []` groups results per namespace. For what is written inside a file or folder, use `describe` |
Expand Down
26 changes: 24 additions & 2 deletions internal/adapters/inbound/cli/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ func newSearchCmd(deps *Deps) *cobra.Command {
var pathPrefix string
var includeWeak bool
var asJSON bool
var compact bool

cmd := &cobra.Command{
Use: "search <query>",
Short: "Full-text search for code nodes",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
if compact && !asJSON {
return fmt.Errorf("--compact requires --json")
}
if limit <= 0 {
return fmt.Errorf("limit must be > 0, got %d", limit)
}
Expand All @@ -54,7 +58,11 @@ func newSearchCmd(deps *Deps) *cobra.Command {
}

if asJSON {
return printJSONResponse(stdout(cmd), searchwire.NewResponse(list, query, limit, offset, false))
response := searchwire.NewResponse(list, query, limit, offset, false)
if compact {
return printCompactJSONResponse(stdout(cmd), response.Compact())
}
return printJSONResponse(stdout(cmd), response)
}
printEvidenceList(stdout(cmd), list, offset)
return nil
Expand All @@ -66,19 +74,33 @@ func newSearchCmd(deps *Deps) *cobra.Command {
cmd.Flags().StringVar(&pathPrefix, "path", "", "Filter results to file paths starting with this prefix (e.g. internal/auth)")
cmd.Flags().BoolVar(&includeWeak, "include-weak", false, "Also show candidates whose name, path, and @intent say nothing about the query")
cmd.Flags().BoolVar(&asJSON, "json", false, "Print the answer as JSON, in the same shape the MCP search tool returns")
cmd.Flags().BoolVar(
&compact,
"compact",
false,
"With --json, omit redundant fields while preserving evidence, source bounds, and next actions",
)

return cmd
}

// printJSONResponse writes the wire payload as one indented JSON document.
// @intent keep --json output byte-stable and diffable while staying the MCP contract.
// @sideEffect writes the whole search answer to out.
func printJSONResponse(out io.Writer, response searchwire.Response) error {
func printJSONResponse(out io.Writer, response any) error {
encoder := json.NewEncoder(out)
encoder.SetIndent("", " ")
return trace.Wrap(encoder.Encode(response), "encode search response")
}

// printCompactJSONResponse writes one unindented JSON document so formatting
// whitespace does not consume an agent's context window.
// @intent keep compact search output compact on the wire as well as in its fields.
// @sideEffect writes the whole compact search answer to out.
func printCompactJSONResponse(out io.Writer, response searchwire.CompactResponse) error {
return trace.Wrap(json.NewEncoder(out).Encode(response), "encode compact search response")
}

// printEvidenceList writes one result per unindented line and everything else
// indented, so the plain result lines stay as machine-readable as they were.
//
Expand Down
47 changes: 47 additions & 0 deletions internal/adapters/inbound/cli/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,53 @@ func TestSearchCommand_JSONSpeaksTheMCPContract(t *testing.T) {
}
}

func TestSearchCommand_CompactJSONKeepsOnlyDecisionEvidence(t *testing.T) {
deps, stdout, stderr, db := setupSearchTest(t)
seedSearchData(t, db)

if err := executeCmd(deps, stdout, stderr, "search", "--json", "--compact", "Hello"); err != nil {
t.Fatalf("search: %v", err)
}

var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v output=%s", err, stdout.String())
}
if lines := strings.Count(stdout.String(), "\n"); lines != 1 {
t.Errorf("compact JSON used %d lines, want one encoded document", lines)
}
file := payload["files"].([]any)[0].(map[string]any)
hit := file["hits"].([]any)[0].(map[string]any)
for _, key := range []string{"qualified_name", "kind", "start_line", "end_line", "matched"} {
if _, ok := hit[key]; !ok {
t.Errorf("compact hit is missing %q: %s", key, stdout.String())
}
}
for _, duplicate := range []string{"id", "name", "file_path"} {
if _, ok := hit[duplicate]; ok {
t.Errorf("compact hit kept duplicate field %q: %s", duplicate, stdout.String())
}
}
compactSize := stdout.Len()
stdout.Reset()
if err := executeCmd(deps, stdout, stderr, "search", "--json", "Hello"); err != nil {
t.Fatalf("full search: %v", err)
}
if compactSize*4 >= stdout.Len()*3 {
t.Errorf("compact CLI output is %d bytes versus %d full bytes, want at least 25%% smaller", compactSize, stdout.Len())
}
}

func TestSearchCommand_CompactRequiresJSON(t *testing.T) {
deps, stdout, stderr, db := setupSearchTest(t)
seedSearchData(t, db)

err := executeCmd(deps, stdout, stderr, "search", "--compact", "Hello")
if err == nil || !strings.Contains(err.Error(), "--compact requires --json") {
t.Fatalf("error = %v, want --compact requires --json", err)
}
}

// A truncated --json answer carries the same next actions MCP emits, phrased as
// a repeatable search call.
func TestSearchCommand_JSONNamesTheNextPage(t *testing.T) {
Expand Down
80 changes: 73 additions & 7 deletions internal/adapters/inbound/cli/skills_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,76 @@ func TestProjectInstructionsRouteFastAndVerifiedSearchSeparately(t *testing.T) {
text := string(raw)
for _, phrase := range []string{
"`/ccg` is the fast default",
"at most one `search` call with `limit: 5`",
"environment-aware structured search",
"For this repository, use the local JSON CLI",
"start with one `search` call using `limit: 5`",
"pass the namespace already supplied",
"at most three verbatim `next` calls",
"Use `/ccg-search-verify`",
"freshness, hybrid source checking, and truncation paging",
} {
if !strings.Contains(text, phrase) {
t.Errorf("project instructions are missing search-mode boundary %q", phrase)
}
}
if strings.Contains(text, "use ccg MCP tools and Agent Skills first") {
t.Error("project instructions force MCP even when the fast skill selects a repository-local JSON CLI")
}
}

func TestFastSearchSkillDescribesSoftRetrievalAndAdaptiveVerification(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "skills", "ccg", "SKILL.md"))
if err != nil {
t.Fatal(err)
}
text := strings.ToLower(strings.Join(strings.Fields(string(raw)), " "))
for _, phrase := range []string{
"or matching",
"rejects single-term coincidences",
"bm25/idf",
"search first when the path is unknown",
"use it verbatim",
"do not compress a behavior or reason question into keywords",
"repository-local `.ccg.yaml`",
"use json cli",
"--compact",
"compact: true",
"explicit mcp or server-visible",
"mcp tool availability or mcp documentation alone",
"path-only reference lookup",
"before semantic paging",
"do not re-locate a hit with grep",
"each chosen declaration's exact range separately",
"exact-identifier miss",
"repeat the same local graph query",
"name the specific evidence gap",
"material evidence gap can",
"author-recorded design reason",
"one directly relevant author-recorded design reason",
"closes the rationale gap",
"secondary consequences are optional",
"do not trace constructor or dependency-injection wiring",
"state that gap in one sentence",
"do not create a new evidence gap from identifiers",
"do not load a reference merely because declaration bounds are missing",
"do not read tests to corroborate production behavior already established",
"next action must be the final answer",
} {
if !strings.Contains(text, phrase) {
t.Errorf("fast search skill is missing retrieval contract %q", phrase)
}
}
for _, overfit := range []string{
"default fast budget is two production",
"at most one targeted in-file grep",
"Known filename, identifier, literal, or error text | Grep + Read may start directly",
"Never restart with repository-wide",
"Prefer MCP",
} {
if strings.Contains(text, strings.ToLower(overfit)) {
t.Errorf("fast search skill hard-codes an example-specific budget %q", overfit)
}
}
}

func TestProjectSkillsDoNotAdvertiseRemovedCommands(t *testing.T) {
Expand Down Expand Up @@ -330,8 +392,10 @@ func TestProjectSkillsCoverOperationalHazards(t *testing.T) {
required := map[string][]string{
"ccg": {
"ordinary positive",
"`search` at most once",
"one or two source ranges",
"one initial structured ccg `search`",
"include it in the initial search arguments",
"at most three `next` calls",
"exact declaration",
"do not call `get_minimal_context`",
"`ccg-search-verify`",
},
Expand Down Expand Up @@ -413,9 +477,10 @@ func TestProjectSkillsCentralizeSharedOperationalGuidance(t *testing.T) {
skillsRoot := filepath.Join("..", "..", "..", "..", "skills")
required := map[string][]string{
"ccg": {
"## Task Routing and Entry",
"## Freshness Boundary",
"## Response Budget Rule",
"## Route",
"## Core Loop",
"## Boundaries",
"## Completion",
},
"ccg-search-verify": {
"## Mandatory Verification",
Expand Down Expand Up @@ -474,7 +539,8 @@ func TestProjectSkillsKeepCoreDiscoveryBoundedAndDeepAnalysisExplicit(t *testing
required := map[string][]string{
"ccg": {
"do not invoke `ccg-analyze` automatically",
"one bounded `search`",
"one initial structured ccg `search`",
"at most three `next` calls",
"explicit `ccg-analyze` invocation",
},
"ccg-analyze": {
Expand Down
Loading
Loading