diff --git a/.changeset/codebase-map-bootstrap.md b/.changeset/codebase-map-bootstrap.md new file mode 100644 index 00000000..e9ccaaf9 --- /dev/null +++ b/.changeset/codebase-map-bootstrap.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Add hash-stable `map_id` and `codebase_map` routing hints to `context` responses (CLI, MCP, HTTP). MCP initialize instructions now include `map_id` and top hub paths. Opt out with `--no-codebase-map` or `include_codebase_map: false`; omitted when `compact`. diff --git a/docs/agents.md b/docs/agents.md index 19ee1e29..d720d01a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -131,7 +131,7 @@ See [architecture.md § Session lifecycle wiring](./architecture.md#session-life ## MCP tool allowlist -**`context.index_freshness`** — session bootstrap includes index-level freshness metadata: `commit_drift` (HEAD ≠ `last_indexed_commit`), `pending_sync` (watcher debounce queue or in-flight reindex), optional disk-drift counts when watch is off, and a single `warning` string when agents should pause or re-index. **`context.start_here`** (non-compact) adds inline index summary, intent-ranked `query_recipe` cards, and top hub files with export signatures (adaptive caps by file count; optional MCP/HTTP `include_snippets` for one-line previews). Debug intent biases `sample_markers` toward FIXME/TODO. **MCP:** array-shaped JSON tools (`query`, …) keep row payloads verbatim and append a second `content` block prefixed `@codemap/index_freshness`; object-shaped tools merge `index_freshness` inline. **HTTP:** `POST /tool/*` adds `X-Codemap-Pending-Sync`, `X-Codemap-Commit-Drift`, and `X-Codemap-Warning` headers without changing JSON bodies; **`GET /health`** includes full cheap `index_freshness` when the DB is readable. Complements per-file `validate` / snippet `stale`. See [architecture.md § Context wiring](./architecture.md#context-wiring). +**`context.index_freshness`** — session bootstrap includes index-level freshness metadata: `commit_drift` (HEAD ≠ `last_indexed_commit`), `pending_sync` (watcher debounce queue or in-flight reindex), optional disk-drift counts when watch is off, and a single `warning` string when agents should pause or re-index. **`context.start_here`** (non-compact) adds inline index summary, intent-ranked `query_recipe` cards, and top hub files with export signatures (adaptive caps by file count; optional MCP/HTTP `include_snippets` for one-line previews). **`context.map_id`** + **`context.codebase_map`** (non-compact, default on) add a hash-stable routing card: top hub paths plus codemap CLI outcome aliases and session-start MCP tools — omit with `--no-codebase-map` / `include_codebase_map: false` or `--compact`. MCP initialize **`instructions`** also carry `map_id` and top three hubs; call **`context`** for the full map. Debug intent biases `sample_markers` toward FIXME/TODO. **MCP:** array-shaped JSON tools (`query`, …) keep row payloads verbatim and append a second `content` block prefixed `@codemap/index_freshness`; object-shaped tools merge `index_freshness` inline. **HTTP:** `POST /tool/*` adds `X-Codemap-Pending-Sync`, `X-Codemap-Commit-Drift`, and `X-Codemap-Warning` headers without changing JSON bodies; **`GET /health`** includes full cheap `index_freshness` when the DB is readable. Complements per-file `validate` / snippet `stale`. See [architecture.md § Context wiring](./architecture.md#context-wiring). **MCP ToolAnnotations** — `tools/list` (and HTTP `GET /tools`) expose advisory `readOnlyHint` / `destructiveHint` / `idempotentHint` per tool so clients can gate auto-approval. Read paths (`query`, `show`, `audit`, …) → `readOnlyHint: true`; disk-write apply tools → `destructiveHint: true` (writes still require `yes: true`); index mutators (`save_baseline`, `drop_baseline`, `ingest_coverage`, `ingest_churn`) → `readOnlyHint: false` without `destructiveHint`. diff --git a/docs/architecture.md b/docs/architecture.md index 3cd0ea1e..adac4929 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -134,7 +134,7 @@ A local SQLite database (`.codemap/index.db`) indexes the project tree and store #### Context wiring -**`src/cli/cmd-context.ts`** (argv + render) → **`handleContext`** in **`tool-handlers.ts`** → **`src/application/context-engine.ts`** (engine — **`buildContextEnvelope`**, **`classifyIntent`**, **`composeStartHere`**, **`resolveContextBudget`**, `ContextEnvelope` type). `buildContextEnvelope` composes the JSON envelope from existing recipes (legacy **`hubs`** at the bundled `fan-in` recipe default limit; budget-capped **`start_here.hub_leaders`** via **`resolveContextBudget(file_count)`**), intent-scoped `sample_markers`, `QUERY_RECIPES` catalog, **`start_here`** (inline `index-summary`, intent-ranked `query_recipe` cards, hub leaders with exported-symbol signatures — optional one-line **`include_snippets`** via CLI `--include-snippets` or MCP/HTTP `include_snippets`, path-contained disk reads with `stale`/`missing` flags), and **`index_freshness`** via **`src/application/index-freshness.ts`**. Debug `--for` / MCP `intent` biases markers toward FIXME/TODO kinds; whitespace-only intent is treated as no intent on all transports. **`classifyIntent`** maps free text to `refactor | debug | test | feature | explore | other`; **`start_here.classified_as`** is `"default"` when no intent is supplied. Hub-leader **`include_snippets`** one-liners share the adaptive **`signature_max_chars`** cap. **`--compact`** drops `hubs`, `sample_markers`, and `start_here` and emits minified JSON (non-compact pretty-prints with 2-space indent). Whitespace-only `--for` values are rejected at CLI parse time. **`include_snippets`** is a no-op when **`compact: true`**. Product-shape constraint: [No split-brain incremental index](./roadmap.md#floors-v1-product-shape). +**`src/cli/cmd-context.ts`** (argv + render) → **`handleContext`** in **`tool-handlers.ts`** → **`src/application/context-engine.ts`** (engine — **`buildContextEnvelope`**, **`classifyIntent`**, **`composeStartHere`**, **`resolveContextBudget`**, **`buildCodebaseMap`**, **`computeMapId`**, **`buildCliEntryHints`**, `ContextEnvelope` type). `buildContextEnvelope` composes the JSON envelope from existing recipes (legacy **`hubs`** at the bundled `fan-in` recipe default limit; budget-capped **`start_here.hub_leaders`** via **`resolveContextBudget(file_count)`**), intent-scoped `sample_markers`, `QUERY_RECIPES` catalog, **`start_here`** (inline `index-summary`, intent-ranked `query_recipe` cards, hub leaders with exported-symbol signatures — optional one-line **`include_snippets`** via CLI `--include-snippets` or MCP/HTTP `include_snippets`, path-contained disk reads with `stale`/`missing` flags), optional **`map_id`** + **`codebase_map`** (hub paths from `start_here.hub_leaders` + static codemap CLI/MCP routing hints from **`src/outcome-aliases.ts`** and session-start MCP tools — not app runtime entry files), and **`index_freshness`** via **`src/application/index-freshness.ts`**. **`map_id`** is the first 16 hex chars of `hashContent(JSON.stringify(canonical))` over sorted `hub_paths`, `index_summary`, `schema_version`, `file_count`, and `last_indexed_commit` — agents compare ids across sessions without re-fetching full **`start_here`**. Debug `--for` / MCP `intent` biases markers toward FIXME/TODO kinds; whitespace-only intent is treated as no intent on all transports. **`classifyIntent`** maps free text to `refactor | debug | test | feature | explore | other`; **`start_here.classified_as`** is `"default"` when no intent is supplied. Hub-leader **`include_snippets`** one-liners share the adaptive **`signature_max_chars`** cap. **`--compact`** drops `hubs`, `sample_markers`, `start_here`, `map_id`, and `codebase_map` and emits minified JSON (non-compact pretty-prints with 2-space indent). **`--no-codebase-map`** / MCP/HTTP `include_codebase_map: false` omits `map_id` and `codebase_map` while keeping `start_here`. Whitespace-only `--for` values are rejected at CLI parse time. **`include_snippets`** is a no-op when **`compact: true`**. MCP **`runMcpServer`** appends a short auto-generated codebase-map block (`map_id` + top three hub paths) to initialize **`instructions`** after bootstrap. Product-shape constraint: [No split-brain incremental index](./roadmap.md#floors-v1-product-shape). **Impact wiring:** **`src/cli/cmd-impact.ts`** (argv — `` + `--in ` + `--direction up|down|both` + `--depth N` + `--via dependencies|calls|imports|all` + `--limit N` + `--summary` + `--json`; bootstrap absorbs `--root`/`--config`) + **`src/application/impact-engine.ts`** (engine — `findImpact({db, target, direction?, via?, depth?, limit?, inPath?})`). Pure transport-agnostic walker over the calls + dependencies + imports graphs; **`--via calls`** walks only parse-resolved `calls` rows (`CALLS_AST_ONLY_SQL` — excludes callback-synthesis heuristics unless consumers query `calls-including-heuristic`); CLI / MCP / HTTP all dispatch the same engine function via `tool-handlers.ts`'s `handleImpact` (MCP/HTTP `in` arg). Target auto-resolves: contains `/` or matches `files.path` → file target; otherwise symbol (case-sensitive). **Homonym symbols** (`matched_in.length > 1`): unscoped walks union per-defining-file call graphs (first hop scoped to each definition's call sites); `--in` / MCP `in` filters `matched_in` via show-engine prefix/exact rules — no match → empty `matches` + `skipped_scope`. Walks compatible backends per resolved kind: **symbol** → `calls` (callers / callees by `caller_name` / `callee_name`); **file** → `dependencies` (`from_path` / `to_path`) + `imports` (`file_path` / `resolved_path`, `IS NOT NULL` filter). `--via ` overrides; mismatched explicit choices land in `skipped_backends` (no error — agents see why their backend selection yielded fewer rows than expected). One `WITH RECURSIVE` per (direction, backend) combo with cycle detection via path-string `instr` check (SQLite has no native cycle predicate); JS-side merge + dedup by `(direction, kind, name?, file_path)` keeping the shallowest depth. `--depth 0` uses an unbounded sentinel (`UNBOUNDED_DEPTH_SENTINEL = 1_000_000`); cycle detection + `LIMIT` keep cyclic graphs cheap regardless. Termination reason classification: `limit` (truncated) > `depth` (any node sat at the cap) > `exhausted`. Result envelope: `{target, direction, via, depth_limit, matches: [{depth, direction, edge, kind, name?, file_path}], summary: {nodes, max_depth_reached, by_kind, terminated_by}, skipped_backends?, skipped_scope?}`. `--summary` blanks `matches` (transport bandwidth saver) but preserves `summary.nodes` so CI gates (`jq '.summary.nodes'`) still see the count. SARIF / annotations not supported (graph traversal, not findings — the parser accepts the flag combos but the engine only emits JSON). diff --git a/docs/plans/codebase-map-bootstrap.md b/docs/plans/codebase-map-bootstrap.md deleted file mode 100644 index e6322c0f..00000000 --- a/docs/plans/codebase-map-bootstrap.md +++ /dev/null @@ -1,125 +0,0 @@ -# Codebase map in bootstrap responses — plan - -> **Status:** open · **Priority:** P2 (agent warm-path) · **Effort:** S–M (~3–5 days) -> -> **Motivator:** Agents call `context` at session start today but lack a compact, hash-stable routing card: which codemap CLI/MCP verbs to reach for first, and whether the structural summary changed since the last session. Roadmap marks this **partial** — `hubs`, `start_here.index_summary`, `index_freshness`, and `codemap.schema_version` already ship on `context`; **`cli_entry_hints`** and **`map_id`** do not. -> -> **Roadmap:** [§ Agent session & warm-path economics — Codebase map](../roadmap.md#agent-session--warm-path-economics) -> -> **Not in scope:** Framework entry-point substrate (`files.is_entry`) — tracked separately at [`c9-plugin-layer.md`](./c9-plugin-layer.md). “CLI entry hints” here means **codemap command/tool routing**, not app runtime entry files. - ---- - -## Agent start here - -Read **`ContextEnvelope`** and **`composeStartHere`** in [`context-engine.ts`](../../src/application/context-engine.ts) first. Do not re-implement `start_here` — add a sibling **`codebase_map`** object and **`map_id`** at the envelope root. - -### Shipped today (do not rebuild) - -| Surface | Ships | Does not ship | -| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| **`context` tool** / `codemap context` | `codemap.cli_version`, `codemap.schema_version`, `project.*`, `recipes[]`, `index_freshness`, optional `hubs`, `sample_markers`, `start_here` (`index_summary`, intent recipe cards, `hub_leaders` + signatures) | `map_id`, `codebase_map`, `cli_entry_hints` | -| **MCP `initialize`** | Markdown `instructions` from [`assembleMcpInstructions()`](../../src/application/agent-content.ts) (`templates/agent-content/mcp-instructions.md`) | JSON structural map; no auto-`context` call at boot ([`runMcpServer`](../../src/application/mcp-server.ts) only bootstraps DB + optional watch) | -| **Opt-out** | `--compact` / MCP `compact: true` drops `hubs`, `sample_markers`, `start_here` ([`context-engine.ts:297-323`](../../src/application/context-engine.ts)) | No dedicated codebase-map opt-out flag | - -### Key touchpoints - -| File | Role | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| [`src/application/context-engine.ts`](../../src/application/context-engine.ts) | `ContextEnvelope` type, `buildContextEnvelope`, `composeStartHere`, `resolveContextBudget` | -| [`src/application/index-freshness.ts`](../../src/application/index-freshness.ts) | `computeIndexFreshness` (already merged into envelope) | -| [`src/application/tool-handlers.ts`](../../src/application/tool-handlers.ts) | `contextArgsSchema`, `handleContext` | -| [`src/cli/cmd-context.ts`](../../src/cli/cmd-context.ts) | `--compact`, `--for`, `--include-snippets` argv | -| [`src/application/mcp-server.ts`](../../src/application/mcp-server.ts) | `registerContextTool`, `createMcpServer` initialize `instructions` | -| [`src/application/agent-content.ts`](../../src/application/agent-content.ts) | `assembleMcpInstructions` (Slice 2 hook) | -| [`src/cli/aliases.ts`](../../src/cli/aliases.ts) | `OUTCOME_ALIASES` — five outcome-shaped CLI aliases | -| [`src/application/mcp-tool-allowlist.ts`](../../src/application/mcp-tool-allowlist.ts) | `MCP_TOOL_NAMES` (21 tools) | -| [`src/hash.ts`](../../src/hash.ts) | `hashContent` (SHA-256) for `map_id` | -| [`src/application/context-engine.test.ts`](../../src/application/context-engine.test.ts) | Envelope + `composeStartHere` tests | -| [`src/application/mcp-server.test.ts`](../../src/application/mcp-server.test.ts) | MCP `context` + initialize instructions | -| [`templates/agent-content/mcp-instructions.md`](../../templates/agent-content/mcp-instructions.md) | Session-start playbook (Slice 2 cross-ref) | - ---- - -## Pre-locked decisions - -| # | Decision | Source | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| L.1 | Add **`codebase_map`** on `ContextEnvelope` — sibling to `start_here`, not a replacement. | Preserve shipped `start_here` consumers | -| L.2 | **`map_id`** = first **16** hex chars of `hashContent(JSON.stringify(canonical))` where `canonical` uses **sorted** `hub_paths: string[]`, `index_summary` object, `schema_version`, `file_count`, `last_indexed_commit` (from existing envelope fields). Same inputs → same id across transports. | [`hash.ts`](../../src/hash.ts); stable agent cache key | -| L.3 | **`cli_entry_hints`** = structured static routing rows sourced from code constants — **not** inferred from indexed repo files. Minimum rows: (a) five [`OUTCOME_ALIASES`](../../src/cli/aliases.ts) (`dead-code` → `untested-and-dead`, …); (b) session-start MCP tools: `context`, `show`, `query_recipe`, `trace`, `explore`, `node`, `validate` (matches [`mcp-instructions.md` Session start](../../templates/agent-content/mcp-instructions.md)). Shape: `{ surface: "cli" \| "mcp", id: string, maps_to: string, note?: string }`. | Roadmap “CLI entry hints”; distinct from C.9 `is_entry` | -| L.4 | **Opt-out:** new `--no-codebase-map` CLI flag + MCP/HTTP `include_codebase_map?: boolean` (default **true** when not `compact`). When `compact: true`, omit `codebase_map` and `map_id` regardless of flag. | Roadmap “opt-out via flag”; keep `compact` semantics | -| L.5 | **No `SCHEMA_VERSION` bump** — JSON-only envelope fields. | Moat B discipline; no DDL | -| L.6 | **MCP initialize Slice 2 (optional):** append a short auto-generated block to `assembleMcpInstructions()` output: `map_id` + top 3 `hub_paths` + link to call `context` for full map. MCP SDK exposes no structured initialize JSON beyond `instructions` ([`createMcpServer`](../../src/application/mcp-server.ts:157-164)). | Factual transport constraint | - ---- - -## Target envelope shape (Slice 1) - -```typescript -// Added to ContextEnvelope in context-engine.ts -map_id?: string; // omitted when compact / --no-codebase-map -codebase_map?: { - hub_paths: string[]; // from start_here.hub_leaders[].file_path (same budget cap) - cli_entry_hints: { - surface: "cli" | "mcp"; - id: string; - maps_to: string; - note?: string; - }[]; -}; -``` - -`map_id` is computed **after** `hub_paths` are known so agents can compare ids without re-fetching full `start_here`. - ---- - -## Implementation slices - -| Slice | Scope | Ship gate | -| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| **1** | Types + `buildCodebaseMap()` + `computeMapId()` in `context-engine.ts`; wire `handleContext` / `cmd-context`; tests in `context-engine.test.ts` + `tool-handlers.test.ts` | Tracer bullet — land first | -| **2** | MCP initialize instructions append (requires DB at `assembleMcpInstructions` time **or** lazy placeholder + “call `context`”) — pick one in PR; update `mcp-instructions.md` + `mcp-server.test.ts` | Only after Slice 1 green | -| **3** | Docs: [`architecture.md` § Context wiring](../architecture.md), [`agents.md`](../agents.md) bootstrap table; roadmap item → check `[x]` + delete this plan when closed | Same PR as Slice 1–2 or follow-up | - -### Tracer bullet (Slice 1) - -1. Add `buildCodebaseMap({ hubLeaders, compact, include })` returning `undefined` when `compact || !include`. -2. Add `computeMapId(canonical)` using `hashContent`. -3. Extend `contextArgsSchema` + `parseContextRest` with opt-out flag. -4. Test: stable `map_id` for fixed fixture DB; opt-out omits fields; `compact` omits fields. - -### Out of scope - -- `files.is_entry` / framework route substrate ([`c9-plugin-layer.md`](./c9-plugin-layer.md), [`framework-route-extraction.md`](./framework-route-extraction.md)) -- Auto-invoking `context` inside `runMcpServer` (extra index work on every MCP boot) -- New MCP resource `codemap://map` (defer unless a consumer requests it) -- Verdict / pass-fail on map freshness (Moat A) - ---- - -## Acceptance - -- [ ] `codemap context --json` includes `map_id` + `codebase_map.cli_entry_hints` with all five outcome aliases and seven session-start MCP tools (L.3) -- [ ] Re-running `context` on unchanged index returns identical `map_id` -- [ ] `codemap context --compact` and `--no-codebase-map` omit `map_id` and `codebase_map` -- [ ] MCP `context` JSON matches CLI envelope (parity via `handleContext`) -- [ ] `bun test src/application/context-engine.test.ts src/application/tool-handlers.test.ts src/application/mcp-server.test.ts` - ---- - -## Verification - -```bash -bun test src/application/context-engine.test.ts src/application/tool-handlers.test.ts -bun src/index.ts context --json | jq '.map_id, .codebase_map.cli_entry_hints | length' -bun src/index.ts context --no-codebase-map --json | jq 'has("map_id")' # expect false after implementation -bun src/index.ts context --compact --json | jq 'has("codebase_map")' # expect false after implementation -``` - ---- - -## Dependencies - -- Shipped: `start_here`, `index_freshness`, `OUTCOME_ALIASES`, 21-tool MCP allowlist -- Independent of: C.9 plugin layer, FTS default-on, tiered lookup fast paths diff --git a/docs/roadmap.md b/docs/roadmap.md index a857bf63..99b55a33 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,7 +69,7 @@ Prioritized agent & indexing ops queue (2026-05). Reference: [agents.md](./agent Long-running MCP / HTTP sessions dominate agent workflows; one-shot CLI keeps the sub-100ms cold-start floor ([§ Floors — No daemon for one-shot CLI](./roadmap.md#floors-v1-product-shape)). Items here apply to **`mcp` / `serve` / `watch` only** unless noted. - [ ] **MCP shared daemon per project** — one watcher + one SQLite writer per indexed root; Unix socket / named pipe so concurrent agent sessions share a live index instead of each spawning watchers and contending on WAL. Complements perf item **6.1** (read pool) but is a separate write-side + lifecycle concern. Effort: L. -- [ ] **Codebase map in bootstrap responses** — hash-stable structural summary (top hubs, CLI entry hints, schema version, index freshness) auto-included in `context` / MCP initialize payload. **Partial:** hubs + `start_here.index_summary` + `index_freshness` ship on `context`; CLI entry hints + hash-stable map id still open. Plan: [`plans/codebase-map-bootstrap.md`](./plans/codebase-map-bootstrap.md). Effort: S–M. +- [x] **Codebase map in bootstrap responses** — `context` ships `map_id` + `codebase_map` (hub paths, codemap CLI/MCP routing hints); MCP initialize `instructions` append `map_id` + top hubs. Opt-out: `--no-codebase-map` / `include_codebase_map: false`; omitted when `compact`. See [architecture.md § Context wiring](./architecture.md#context-wiring). - [ ] **`--mcp-invocation global|auto` flag** — explicit override to force global `codemap` on PATH vs PM-aware auto-resolve. Effort: S. - [ ] **`agents init` uninstall (teardown)** — symmetric inverse of init for failed pilots, template mistakes, or leaving a repo: remove codemap-managed MCP entries, pointer sections, and IDE symlinks only (same scoped paths as init; never delete user-authored `.agents/` siblings). `--target` filter, `--yes` non-interactive. Not the happy-path docs story — adoption stays `init --mcp --git-hooks` + committed `.agents/`. Effort: S. diff --git a/src/application/agent-content.ts b/src/application/agent-content.ts index 00773453..8aef3ba8 100644 --- a/src/application/agent-content.ts +++ b/src/application/agent-content.ts @@ -43,9 +43,11 @@ const MCP_INSTRUCTIONS_FILE = "mcp-instructions.md"; const MCP_RECIPE_REFS_RE = //; /** MCP initialize playbook — `templates/agent-content/mcp-instructions.md`. */ -export function assembleMcpInstructions(): string { +export function assembleMcpInstructions(appendix?: string): string { const path = join(resolveAgentContentDir(), MCP_INSTRUCTIONS_FILE); - return readFileSync(path, "utf8").trimEnd() + "\n"; + const base = readFileSync(path, "utf8").trimEnd() + "\n"; + if (appendix === undefined || appendix === "") return base; + return base + appendix; } /** Recipe ids declared in the MCP instructions machine-ref comment. */ diff --git a/src/application/context-engine.test.ts b/src/application/context-engine.test.ts index 62bb5c7c..0acb11d4 100644 --- a/src/application/context-engine.test.ts +++ b/src/application/context-engine.test.ts @@ -20,17 +20,22 @@ import { setMeta, } from "../db"; import type { DependencyRow, SymbolRow } from "../db"; +import { OUTCOME_ALIASES } from "../outcome-aliases"; import { initCodemap } from "../runtime"; import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; import { + buildCliEntryHints, buildContextEnvelope, capRecipeSqlLimit, classifyIntent, composeStartHere, + computeMapId, defaultStartHereClassification, + formatCodebaseMapMcpAppendix, normalizeContextIntent, readRecipeSqlLimit, resolveContextBudget, + SESSION_START_MCP_TOOLS, } from "./context-engine"; import * as indexEngine from "./index-engine"; @@ -457,7 +462,157 @@ describe("composeStartHere", () => { }); }); +describe("buildCliEntryHints", () => { + it("includes five outcome aliases and seven session-start MCP tools", () => { + const hints = buildCliEntryHints(); + const cli = hints.filter((h) => h.surface === "cli"); + const mcp = hints.filter((h) => h.surface === "mcp"); + expect(cli).toHaveLength(Object.keys(OUTCOME_ALIASES).length); + expect(mcp.map((h) => h.id)).toEqual([...SESSION_START_MCP_TOOLS]); + expect(cli.find((h) => h.id === "hotspots")?.maps_to).toBe( + "query --recipe fan-in", + ); + expect(mcp.find((h) => h.id === "context")?.note).toContain("map_id"); + }); +}); + +describe("computeMapId", () => { + it("is stable for the same canonical inputs regardless of hub_paths order", () => { + const summary = { + files: 3, + symbols: 2, + imports: 0, + components: 0, + dependencies: 2, + file_churn: 0, + }; + const base = { + index_summary: summary, + schema_version: 1, + file_count: 3, + last_indexed_commit: "abc123", + }; + const a = computeMapId({ ...base, hub_paths: ["src/b.ts", "src/a.ts"] }); + const b = computeMapId({ ...base, hub_paths: ["src/a.ts", "src/b.ts"] }); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{16}$/); + }); +}); + +describe("formatCodebaseMapMcpAppendix", () => { + it("lists map_id and top three hub paths", () => { + const text = formatCodebaseMapMcpAppendix("deadbeefcafebabe", [ + "src/hub.ts", + "src/other.ts", + "src/leaf.ts", + "src/extra.ts", + ]); + expect(text).toContain("map_id: `deadbeefcafebabe`"); + expect(text).toContain("`src/hub.ts`"); + expect(text).not.toContain("`src/extra.ts`"); + expect(text).toContain("call MCP tool `context`"); + }); + + it("handles empty hub paths", () => { + const text = formatCodebaseMapMcpAppendix("abc", []); + expect(text).toContain("top hubs: (none indexed)"); + }); +}); + describe("buildContextEnvelope", () => { + it("includes map_id and codebase_map in non-compact mode", () => { + const revParse = spyOn(indexEngine, "getCurrentCommit").mockReturnValue(""); + try { + withSeededDb((db) => { + const envelope = buildContextEnvelope(db, benchDir, { + compact: false, + intent: null, + }); + expect(envelope.map_id).toMatch(/^[0-9a-f]{16}$/); + expect(envelope.codebase_map?.hub_paths).toEqual( + envelope.start_here?.hub_leaders.map((h) => h.file_path), + ); + expect(envelope.codebase_map?.cli_entry_hints.length).toBe( + Object.keys(OUTCOME_ALIASES).length + SESSION_START_MCP_TOOLS.length, + ); + }); + } finally { + revParse.mockRestore(); + } + }); + + it("returns identical map_id for unchanged index", () => { + const revParse = spyOn(indexEngine, "getCurrentCommit").mockReturnValue(""); + try { + withSeededDb((db) => { + const opts = { compact: false, intent: null }; + const first = buildContextEnvelope(db, benchDir, opts); + const second = buildContextEnvelope(db, benchDir, opts); + expect(first.map_id).toBe(second.map_id); + }); + } finally { + revParse.mockRestore(); + } + }); + + it("changes map_id when hub rankings change", () => { + const revParse = spyOn(indexEngine, "getCurrentCommit").mockReturnValue(""); + try { + withSeededDb((db) => { + const before = buildContextEnvelope(db, benchDir, { + compact: false, + intent: null, + }); + insertDependencies(db, [ + { from_path: "src/hub.ts", to_path: "src/leaf.ts" }, + { from_path: "src/hub.ts", to_path: "src/other.ts" }, + ]); + const after = buildContextEnvelope(db, benchDir, { + compact: false, + intent: null, + }); + expect(before.map_id).not.toBe(after.map_id); + }); + } finally { + revParse.mockRestore(); + } + }); + + it("omits map fields when include_codebase_map is false", () => { + const revParse = spyOn(indexEngine, "getCurrentCommit").mockReturnValue(""); + try { + withSeededDb((db) => { + const envelope = buildContextEnvelope(db, benchDir, { + compact: false, + intent: null, + include_codebase_map: false, + }); + expect(envelope.start_here).toBeDefined(); + expect(envelope.map_id).toBeUndefined(); + expect(envelope.codebase_map).toBeUndefined(); + }); + } finally { + revParse.mockRestore(); + } + }); + + it("omits map fields when compact", () => { + const revParse = spyOn(indexEngine, "getCurrentCommit").mockReturnValue(""); + try { + withSeededDb((db) => { + const envelope = buildContextEnvelope(db, benchDir, { + compact: true, + intent: null, + include_codebase_map: true, + }); + expect(envelope.map_id).toBeUndefined(); + expect(envelope.codebase_map).toBeUndefined(); + }); + } finally { + revParse.mockRestore(); + } + }); + it("includes start_here in non-compact mode", () => { const head = "cccccccccccccccccccccccccccccccccccccccc"; const revParse = spyOn(indexEngine, "getCurrentCommit").mockReturnValue( diff --git a/src/application/context-engine.ts b/src/application/context-engine.ts index f9b4b848..cc795198 100644 --- a/src/application/context-engine.ts +++ b/src/application/context-engine.ts @@ -2,6 +2,8 @@ import { resolve } from "node:path"; import { getMeta, SCHEMA_VERSION } from "../db"; import type { CodemapDatabase } from "../db"; +import { hashContent } from "../hash"; +import { OUTCOME_ALIASES } from "../outcome-aliases"; import { CODEMAP_VERSION } from "../version"; import { computeIndexFreshness } from "./index-freshness"; import type { IndexFreshness } from "./index-freshness"; @@ -13,6 +15,37 @@ import { getIndexedContentHash, readSymbolSource } from "./show-engine"; const START_HERE_RECIPE_LIMIT = 4; const DEFAULT_MARKER_LIMIT = 20; +/** Session-start MCP tools cited in `cli_entry_hints` (matches mcp-instructions.md). */ +export const SESSION_START_MCP_TOOLS = [ + "context", + "show", + "query_recipe", + "trace", + "explore", + "node", + "validate", +] as const; + +export interface ContextCliEntryHint { + surface: "cli" | "mcp"; + id: string; + maps_to: string; + note?: string; +} + +export interface ContextCodebaseMap { + hub_paths: string[]; + cli_entry_hints: ContextCliEntryHint[]; +} + +export interface MapIdCanonical { + hub_paths: string[]; + index_summary: ContextIndexSummary; + schema_version: number; + file_count: number; + last_indexed_commit: string | null; +} + export interface ContextBudget { hub_limit: number; signatures_per_hub: number; @@ -83,6 +116,10 @@ export interface ContextEnvelope { * signatures. Replaces a common show → explore chain after bootstrap. */ start_here?: ContextStartHere; + /** Hash-stable fingerprint of hub paths + index summary + schema (omit when compact / opt-out). */ + map_id?: string; + /** Routing card: top hub paths + codemap CLI/MCP entry hints (omit when compact / opt-out). */ + codebase_map?: ContextCodebaseMap; recipes: { id: string; description: string }[]; index_freshness: IndexFreshness; intent?: { @@ -140,6 +177,105 @@ export interface BuildContextEnvelopeOpts { intent: string | null; /** One-line export previews on hub leader signatures (CLI `--include-snippets`, MCP/HTTP `include_snippets`). */ include_snippets?: boolean; + /** When false, omit `map_id` and `codebase_map` (default true when not `compact`). */ + include_codebase_map?: boolean; +} + +/** Static codemap CLI/MCP routing rows for `codebase_map.cli_entry_hints`. */ +export function buildCliEntryHints(): ContextCliEntryHint[] { + const hints: ContextCliEntryHint[] = []; + for (const [alias, recipeId] of Object.entries(OUTCOME_ALIASES)) { + hints.push({ + surface: "cli", + id: alias, + maps_to: `query --recipe ${recipeId}`, + }); + } + const mcpNotes: Partial< + Record<(typeof SESSION_START_MCP_TOOLS)[number], string> + > = { + context: "session bootstrap; map_id + cli_entry_hints", + show: "exact symbol lookup", + query_recipe: "bundled SQL recipes", + trace: "call path + snippets", + explore: "multi-symbol neighborhood", + node: "one-hop symbol card", + validate: "per-file index drift", + }; + for (const tool of SESSION_START_MCP_TOOLS) { + const note = mcpNotes[tool]; + hints.push({ + surface: "mcp", + id: tool, + maps_to: tool, + ...(note !== undefined ? { note } : {}), + }); + } + return hints; +} + +/** First 16 hex chars of SHA-256 over canonical JSON (sorted hub_paths). */ +export function computeMapId(canonical: MapIdCanonical): string { + const payload = { + hub_paths: [...canonical.hub_paths].sort(), + index_summary: canonical.index_summary, + schema_version: canonical.schema_version, + file_count: canonical.file_count, + last_indexed_commit: canonical.last_indexed_commit, + }; + return hashContent(JSON.stringify(payload)).slice(0, 16); +} + +export function buildCodebaseMap(opts: { + hubLeaders: ContextHubLeader[]; + include: boolean; + compact: boolean; +}): ContextCodebaseMap | undefined { + if (opts.compact || !opts.include) return undefined; + return { + hub_paths: opts.hubLeaders.map((h) => h.file_path), + cli_entry_hints: buildCliEntryHints(), + }; +} + +/** MCP initialize appendix — map_id + top hubs; full map via `context` tool. */ +export function formatCodebaseMapMcpAppendix( + map_id: string, + hub_paths: string[], +): string { + const top = hub_paths.slice(0, 3); + const hubLine = + top.length > 0 + ? `top hubs: ${top.map((p) => `\`${p}\``).join(", ")}` + : "top hubs: (none indexed)"; + return [ + "", + "## Codebase map (indexed project)", + "", + `map_id: \`${map_id}\``, + hubLine, + "", + "Full routing table and fresh map_id: call MCP tool `context` (or `codemap context --json`).", + ].join("\n"); +} + +/** Build MCP instructions appendix from an open index (after MCP bootstrap). */ +export function buildMcpInstructionsCodebaseMapAppendix( + db: CodemapDatabase, + projectRoot: string, +): string { + const envelope = buildContextEnvelope(db, projectRoot, { + compact: false, + intent: null, + include_codebase_map: true, + }); + if (envelope.map_id === undefined || envelope.codebase_map === undefined) { + return "\n\n## Codebase map\n\nCall MCP tool `context` for the full bootstrap envelope.\n"; + } + return formatCodebaseMapMcpAppendix( + envelope.map_id, + envelope.codebase_map.hub_paths, + ); } /** @@ -320,6 +456,22 @@ export function buildContextEnvelope( fanInRows: hubLeaderRows, }, ); + + const includeMap = opts.include_codebase_map !== false; + envelope.codebase_map = buildCodebaseMap({ + hubLeaders: envelope.start_here.hub_leaders, + include: includeMap, + compact: false, + }); + if (envelope.codebase_map !== undefined) { + envelope.map_id = computeMapId({ + hub_paths: envelope.codebase_map.hub_paths, + index_summary: envelope.start_here.index_summary, + schema_version: SCHEMA_VERSION, + file_count: fileCount, + last_indexed_commit: lastCommit, + }); + } } if (intentClassification !== null && userIntent !== null) { diff --git a/src/application/http-server.test.ts b/src/application/http-server.test.ts index 7d8fa39d..4d3d0abe 100644 --- a/src/application/http-server.test.ts +++ b/src/application/http-server.test.ts @@ -427,6 +427,25 @@ describe("http-server — POST /tool/{other tools}", () => { expect(leader?.signatures?.[0]?.snippet).toContain("export const SNIP"); }); + it("context includes map_id and codebase_map by default", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "context", {}); + expect(r.status).toBe(200); + expect(r.json.map_id).toMatch(/^[0-9a-f]{16}$/); + expect(r.json.codebase_map?.cli_entry_hints?.length).toBe(12); + }); + + it("context omits map fields when include_codebase_map is false", async () => { + serverHandle = await startServer(); + const r = await postTool(serverHandle.port, "context", { + include_codebase_map: false, + }); + expect(r.status).toBe(200); + expect(r.json.start_here).toBeDefined(); + expect(r.json.map_id).toBeUndefined(); + expect(r.json.codebase_map).toBeUndefined(); + }); + it("validate returns staleness rows", async () => { serverHandle = await startServer(); const r = await postTool(serverHandle.port, "validate", {}); diff --git a/src/application/mcp-server.test.ts b/src/application/mcp-server.test.ts index 71931206..1147a5f9 100644 --- a/src/application/mcp-server.test.ts +++ b/src/application/mcp-server.test.ts @@ -22,7 +22,12 @@ import { } from "../db"; import { initCodemap } from "../runtime"; import { installCodemapTestTeardown } from "../test-helpers/runtime-reset"; -import { createMcpServer } from "./mcp-server"; +import { assembleMcpInstructions } from "./agent-content"; +import { buildMcpInstructionsCodebaseMapAppendix } from "./context-engine"; +import { + createMcpServer, + resolveMcpInitializeInstructions, +} from "./mcp-server"; import { MCP_TOOL_NAMES } from "./mcp-tool-allowlist"; import { MCP_TOOL_ANNOTATIONS } from "./mcp-tool-annotations"; @@ -92,6 +97,52 @@ describe("MCP server — initialize instructions", () => { } }); + it("resolveMcpInitializeInstructions honors caller-provided instructions", async () => { + const custom = "custom-mcp-instructions\n"; + const resolved = await resolveMcpInitializeInstructions({ + instructions: custom, + }); + expect(resolved).toBe(custom); + }); + + it("resolveMcpInitializeInstructions assembles codebase map appendix by default", async () => { + const resolved = await resolveMcpInitializeInstructions({}); + expect(resolved).toContain("map_id:"); + expect(resolved).toContain("Session start"); + }); + + it("can append codebase map block to initialize instructions", async () => { + const db = openDb(); + let appendix: string; + try { + appendix = buildMcpInstructionsCodebaseMapAppendix(db, benchDir); + } finally { + closeDb(db, { readonly: true }); + } + expect(appendix).toContain("map_id:"); + expect(appendix).toContain("Codebase map"); + + const server = createMcpServer({ + version: "0.0.0-test", + root: benchDir, + instructions: assembleMcpInstructions(appendix), + }); + const client = new Client({ name: "test-client", version: "0.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + const instructions = client.getInstructions(); + expect(instructions).toContain("map_id:"); + expect(instructions).toContain("call MCP tool `context`"); + } finally { + await server.close(); + } + }); + it("cites only shipped recipe ids", async () => { const { assembleMcpInstructions, extractMcpInstructionRecipeIds } = await import("./agent-content"); diff --git a/src/application/mcp-server.ts b/src/application/mcp-server.ts index 0c8fe784..9b878a80 100644 --- a/src/application/mcp-server.ts +++ b/src/application/mcp-server.ts @@ -13,6 +13,7 @@ import { initCodemap, } from "../runtime"; import { assembleMcpInstructions } from "./agent-content"; +import { buildMcpInstructionsCodebaseMapAppendix } from "./context-engine"; import { formatIndexFreshnessMcpBlock, jsonPayloadNeedsMcpFreshnessBlock, @@ -113,6 +114,8 @@ interface ServerOpts { watch?: boolean; /** Coalesce burst events into one reindex after `debounceMs` of quiet. Only meaningful when `watch: true`. */ debounceMs?: number; + /** Override MCP initialize `instructions` (tests + post-bootstrap appendix). */ + instructions?: string; } /** @@ -160,7 +163,7 @@ export function createMcpServer(opts: ServerOpts): McpServer { version: opts.version, }, { - instructions: assembleMcpInstructions(), + instructions: opts.instructions ?? assembleMcpInstructions(), }, ); @@ -255,7 +258,7 @@ function registerContextTool(server: McpServer): void { "context", withToolAnnotations("context", { description: - "Project bootstrap snapshot — returns the same envelope `codemap context` prints (project root, schema version, file count, start_here shortcuts, recipe catalog, index_freshness). Pass include_snippets for one-line export previews on hub leaders (ignored when compact: true).", + "Project bootstrap snapshot — returns the same envelope `codemap context` prints (project root, schema version, file count, start_here shortcuts, map_id + codebase_map routing card, recipe catalog, index_freshness). Pass include_snippets for one-line export previews on hub leaders (ignored when compact: true). Omit map fields with compact: true or include_codebase_map: false.", inputSchema: contextArgsSchema, }), (args) => wrapToolResult(handleContext(args)), @@ -641,6 +644,26 @@ async function bootstrapForMcp(opts: ServerOpts): Promise { configureResolver(getProjectRoot(), getTsconfigPath()); } +/** Initialize `instructions` for `runMcpServer` — honors `opts.instructions` when set. */ +export async function resolveMcpInitializeInstructions( + opts: Pick, +): Promise { + if (opts.instructions !== undefined) return opts.instructions; + try { + const { openDb, closeDb } = await import("../db"); + const db = openDb(); + try { + return assembleMcpInstructions( + buildMcpInstructionsCodebaseMapAppendix(db, getProjectRoot()), + ); + } finally { + closeDb(db, { readonly: true }); + } + } catch { + return assembleMcpInstructions(); + } +} + /** * Starts the MCP server over stdio. Resolves on client disconnect * (`session-lifecycle.ts`). Logs to stderr per MCP convention. @@ -674,13 +697,15 @@ export async function runMcpServer(opts: ServerOpts): Promise { warnIndexFreshnessToStderr("codemap mcp"); } - const server = createMcpServer(opts); - const transport = new StdioServerTransport(); - if (watchSession !== undefined) { await watchSession.acquireClient(); } + const instructions = await resolveMcpInitializeInstructions(opts); + + const server = createMcpServer({ ...opts, instructions }); + const transport = new StdioServerTransport(); + await server.connect(transport); let shuttingDown = false; diff --git a/src/application/tool-handlers.test.ts b/src/application/tool-handlers.test.ts index a08d41c3..b01134e8 100644 --- a/src/application/tool-handlers.test.ts +++ b/src/application/tool-handlers.test.ts @@ -976,6 +976,32 @@ describe("handleContext", () => { const payload = result.payload as Record; expect(payload.start_here).toBeUndefined(); expect(payload.hubs).toBeUndefined(); + expect(payload.map_id).toBeUndefined(); + expect(payload.codebase_map).toBeUndefined(); + } + }); + + it("includes map_id and codebase_map by default", () => { + const result = handleContext({}); + expect(result.ok).toBe(true); + if (result.ok) { + const payload = result.payload as { + map_id?: string; + codebase_map?: { cli_entry_hints: unknown[] }; + }; + expect(payload.map_id).toMatch(/^[0-9a-f]{16}$/); + expect(payload.codebase_map?.cli_entry_hints.length).toBe(12); + } + }); + + it("omits map fields when include_codebase_map is false", () => { + const result = handleContext({ include_codebase_map: false }); + expect(result.ok).toBe(true); + if (result.ok) { + const payload = result.payload as Record; + expect(payload.start_here).toBeDefined(); + expect(payload.map_id).toBeUndefined(); + expect(payload.codebase_map).toBeUndefined(); } }); }); diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index 7b367763..30f74e46 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -571,12 +571,14 @@ export const contextArgsSchema = { compact: z.boolean().optional(), intent: z.string().optional(), include_snippets: z.boolean().optional(), + include_codebase_map: z.boolean().optional(), }; export interface ContextArgs { compact?: boolean; intent?: string; include_snippets?: boolean; + include_codebase_map?: boolean; } export function handleContext(args: ContextArgs): ToolResult { @@ -587,6 +589,7 @@ export function handleContext(args: ContextArgs): ToolResult { compact: args.compact === true, intent: args.intent ?? null, include_snippets: args.include_snippets, + include_codebase_map: args.include_codebase_map, }); return ok(envelope); } finally { diff --git a/src/cli/aliases.ts b/src/cli/aliases.ts index 1d2890d5..326dddd7 100644 --- a/src/cli/aliases.ts +++ b/src/cli/aliases.ts @@ -1,17 +1,8 @@ -/** - * Outcome-shaped CLI aliases — thin wrappers over `query --recipe `. - * Capped at 5 to avoid alias-sprawl; promote a sixth only when the recipe - * becomes a headline outcome ([roadmap.md](../../docs/roadmap.md)). - */ -export const OUTCOME_ALIASES = Object.freeze({ - "dead-code": "untested-and-dead", - deprecated: "deprecated-symbols", - boundaries: "boundary-violations", - hotspots: "fan-in", - "coverage-gaps": "worst-covered-exports", -} as const); +import { OUTCOME_ALIASES } from "../outcome-aliases"; +import type { OutcomeAlias } from "../outcome-aliases"; -export type OutcomeAlias = keyof typeof OUTCOME_ALIASES; +export { OUTCOME_ALIASES }; +export type { OutcomeAlias }; export function isOutcomeAlias(token: string): token is OutcomeAlias { return Object.hasOwn(OUTCOME_ALIASES, token); diff --git a/src/cli/cmd-cli-parity.test.ts b/src/cli/cmd-cli-parity.test.ts index 8ef4976c..7e9e646b 100644 --- a/src/cli/cmd-cli-parity.test.ts +++ b/src/cli/cmd-cli-parity.test.ts @@ -23,6 +23,7 @@ describe("parseContextRest — include-snippets", () => { compact: false, intent: null, includeSnippets: true, + includeCodebaseMap: true, }); }); }); diff --git a/src/cli/cmd-context.test.ts b/src/cli/cmd-context.test.ts index 82d1ce3d..88cabce0 100644 --- a/src/cli/cmd-context.test.ts +++ b/src/cli/cmd-context.test.ts @@ -15,6 +15,7 @@ describe("parseContextRest", () => { compact: false, intent: null, includeSnippets: false, + includeCodebaseMap: true, }); }); @@ -24,6 +25,17 @@ describe("parseContextRest", () => { compact: true, intent: null, includeSnippets: false, + includeCodebaseMap: true, + }); + }); + + it("parses --no-codebase-map", () => { + expect(parseContextRest(["context", "--no-codebase-map"])).toEqual({ + kind: "run", + compact: false, + intent: null, + includeSnippets: false, + includeCodebaseMap: false, }); }); @@ -33,6 +45,7 @@ describe("parseContextRest", () => { compact: false, intent: "refactor auth", includeSnippets: false, + includeCodebaseMap: true, }); }); @@ -44,6 +57,7 @@ describe("parseContextRest", () => { compact: true, intent: "fix bug", includeSnippets: false, + includeCodebaseMap: true, }); }); @@ -74,6 +88,7 @@ describe("parseContextRest", () => { compact: false, intent: "refactor auth", includeSnippets: false, + includeCodebaseMap: true, }); }); diff --git a/src/cli/cmd-context.ts b/src/cli/cmd-context.ts index 2cdc1833..162914ab 100644 --- a/src/cli/cmd-context.ts +++ b/src/cli/cmd-context.ts @@ -9,31 +9,35 @@ interface ContextOpts { compact: boolean; intent: string | null; includeSnippets: boolean; + includeCodebaseMap: boolean; } /** * Print **`codemap context`** usage. */ export function printContextCmdHelp(): void { - console.log(`Usage: codemap context [--compact] [--for ""] [--include-snippets] + console.log(`Usage: codemap context [--compact] [--for ""] [--include-snippets] [--no-codebase-map] Emit a JSON envelope describing the current index — project metadata, top hubs (fan-in), a sample of markers, session-start shortcuts (start_here), -and the recipe catalog (bundled + project-local). Designed for agents and -editors that want a single-command "give me everything cheap". +map_id / codebase_map routing card, and the recipe catalog (bundled + +project-local). Designed for agents and editors that want a single-command +"give me everything cheap". Flags: - --compact Drop hubs, sample_markers, and start_here; emit JSON - without pretty-print (smaller payload). + --compact Drop hubs, sample_markers, start_here, map_id, and + codebase_map; emit JSON without pretty-print (smaller). --for "" Pre-classify a free-text intent (refactor, debug, test, feature, explore) and recommend recipes that match. --include-snippets One-line export previews on hub leaders (ignored when --compact). Same as MCP \`context\` \`include_snippets\`. + --no-codebase-map Omit map_id and codebase_map (start_here still included). --help, -h Show this help. Examples: codemap context codemap context --compact + codemap context --no-codebase-map codemap context --for "refactor the auth module" codemap context --include-snippets `); @@ -50,6 +54,7 @@ export function parseContextRest(rest: string[]): compact: boolean; intent: string | null; includeSnippets: boolean; + includeCodebaseMap: boolean; } { if (rest[0] !== "context") { throw new Error("parseContextRest: expected context"); @@ -57,6 +62,7 @@ export function parseContextRest(rest: string[]): let compact = false; let intent: string | null = null; let includeSnippets = false; + let includeCodebaseMap = true; for (let i = 1; i < rest.length; i++) { const a = rest[i]; if (a === "--help" || a === "-h") return { kind: "help" }; @@ -64,6 +70,10 @@ export function parseContextRest(rest: string[]): compact = true; continue; } + if (a === "--no-codebase-map") { + includeCodebaseMap = false; + continue; + } if (a === "--include-snippets") { includeSnippets = true; continue; @@ -85,7 +95,7 @@ export function parseContextRest(rest: string[]): message: `codemap context: unknown option "${a}". Run codemap context --help for usage.`, }; } - return { kind: "run", compact, intent, includeSnippets }; + return { kind: "run", compact, intent, includeSnippets, includeCodebaseMap }; } /** @@ -98,6 +108,7 @@ export async function runContextCmd(opts: ContextOpts): Promise { compact: opts.compact, intent: opts.intent ?? undefined, include_snippets: opts.includeSnippets, + include_codebase_map: opts.includeCodebaseMap, }); emitToolResult(result, { json: true, pretty: !opts.compact }); } catch (err) { diff --git a/src/cli/main.ts b/src/cli/main.ts index dd569d12..527142a8 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -153,6 +153,7 @@ Copies bundled agent templates into .agents/ under the project root. compact: parsed.compact, intent: parsed.intent, includeSnippets: parsed.includeSnippets, + includeCodebaseMap: parsed.includeCodebaseMap, }); return; } diff --git a/src/outcome-aliases.ts b/src/outcome-aliases.ts new file mode 100644 index 00000000..98b22a19 --- /dev/null +++ b/src/outcome-aliases.ts @@ -0,0 +1,13 @@ +/** + * Outcome-shaped CLI aliases — thin wrappers over `query --recipe `. + * Shared by `src/cli/aliases.ts` and `context-engine` cli_entry_hints (keep in sync). + */ +export const OUTCOME_ALIASES = Object.freeze({ + "dead-code": "untested-and-dead", + deprecated: "deprecated-symbols", + boundaries: "boundary-violations", + hotspots: "fan-in", + "coverage-gaps": "worst-covered-exports", +} as const); + +export type OutcomeAlias = keyof typeof OUTCOME_ALIASES; diff --git a/templates/agent-content/mcp-instructions.md b/templates/agent-content/mcp-instructions.md index 666c5916..756a5428 100644 --- a/templates/agent-content/mcp-instructions.md +++ b/templates/agent-content/mcp-instructions.md @@ -4,7 +4,7 @@ Operational playbook injected into the MCP initialize handshake. Full schema, re ## Session start -1. **`context`** — project root, schema version, file count, language breakdown, **`start_here`** (index summary + recipe cards + hub leaders), recipe catalog, **`index_freshness`** (one call replaces 4–5 queries). Pass **`include_snippets: true`** for one-line export previews on hub leaders (ignored with **`compact: true`**). Prefer **`start_here.hub_leaders`** over legacy **`hubs`** for signatures. +1. **`context`** — project root, schema version, file count, language breakdown, **`start_here`** (index summary + recipe cards + hub leaders), **`map_id`** + **`codebase_map`** (hub paths + codemap CLI/MCP routing hints), recipe catalog, **`index_freshness`** (one call replaces 4–5 queries). Pass **`include_snippets: true`** for one-line export previews on hub leaders (ignored with **`compact: true`**). Omit map fields with **`compact: true`** or **`include_codebase_map: false`**. Prefer **`start_here.hub_leaders`** over legacy **`hubs`** for signatures. Compare **`map_id`** across sessions to detect structural summary drift without re-fetching full **`start_here`**. 2. **`codemap://rule`** — always-on priming: query the index for structure, don't grep. 3. When you need the catalog or DDL: **`codemap://recipes`**, **`codemap://schema`**. @@ -12,12 +12,12 @@ Operational playbook injected into the MCP initialize handshake. Full schema, re Every successful JSON tool response carries index-level freshness metadata (not a pass/fail verdict): -| Surface | Where to read it | -| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -| **`context`** | `index_freshness`; **`start_here`** when not `compact` (optional `include_snippets`) | -| **Object payloads** (`show`, `query` summary, …) | `index_freshness` merged inline | -| **Array payloads** (`query` rows) | second `content` block prefixed `@codemap/index_freshness` | -| **HTTP** | `X-Codemap-Pending-Sync`, `X-Codemap-Commit-Drift`, `X-Codemap-Warning` headers (JSON body unchanged) | +| Surface | Where to read it | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`context`** | `index_freshness`; **`start_here`** when not `compact` (optional `include_snippets`); **`map_id`** + **`codebase_map`** when not `compact` (optional `include_codebase_map: false`) | +| **Object payloads** (`show`, `query` summary, …) | `index_freshness` merged inline | +| **Array payloads** (`query` rows) | second `content` block prefixed `@codemap/index_freshness` | +| **HTTP** | `X-Codemap-Pending-Sync`, `X-Codemap-Commit-Drift`, `X-Codemap-Warning` headers (JSON body unchanged) | Key fields: `pending_sync` (watcher debounce queue or in-flight reindex), `commit_drift` (`HEAD` ≠ `last_indexed_commit`), `warning` (single agent-readable line when anything is off). diff --git a/templates/agent-content/skill/10-recipes-context.md b/templates/agent-content/skill/10-recipes-context.md index 6cd29fb1..74de1cdc 100644 --- a/templates/agent-content/skill/10-recipes-context.md +++ b/templates/agent-content/skill/10-recipes-context.md @@ -31,7 +31,7 @@ Replace placeholders (`'...'`) with your module path, file glob, or symbol name. Each emitted delta carries its own `base` metadata so mixed-baseline audits are first-class. **`--base `** materialises any git committish via `git archive | tar -x` + reindex (mutually exclusive with `--baseline`). Each `added` row on ref-sourced audits carries **`attribution: introduced | inherited`** — `introduced` = branch-new drift; `inherited` = finding already at merge base (e.g. multiset surplus). Filter branch-only debt with `jq '.deltas.deprecated.added[] | select(.attribution == "introduced")'`. **`--format sarif`** emits SARIF 2.1.0 for Code Scanning; **`--ci`** aliases `--format sarif` + non-zero exit on additions (mutually exclusive with `--json`). `--summary` collapses each delta to `{added: N, removed: N}` plus `added_introduced` / `added_inherited` when `--base` is set. `--no-index` skips the auto-incremental-index prelude (default is to re-index first so `head` reflects current source). v1 ships no `verdict` / threshold config — `codemap audit --json | jq -e '.deltas.dependencies.added | length <= 50'` is the CI exit-code idiom until v1.x ships native thresholds. Each delta pins a canonical SQL projection and validates baseline column-set membership before diffing — schema-bump-resilient (extras dropped, missing columns surface a clean re-save command). -**MCP server (`codemap mcp [--no-watch] [--debounce ]`)** — separate top-level command exposing the structural-query surface (21 JSON-RPC tools — list below) to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) over stdio. Eliminates the bash round-trip on every agent call. Bootstrap once at server boot; each tool returns the same JSON payload its CLI `--json` would print (including `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context --include-snippets`, `ingest-coverage`, and `ingest-churn`). MCP wraps payloads in `{content: [{type: "text", text: …}]}`. **`tools/list` ToolAnnotations** — advisory `readOnlyHint` / `destructiveHint` / `idempotentHint` per tool: read paths (`query`, `show`, `audit`, …) → `readOnlyHint: true`; apply tools (`apply`, `apply_rows`, `apply_diff_input`) → `destructiveHint: true` (writes still require `yes: true`); index mutators (`save_baseline`, `drop_baseline`, `ingest_coverage`, `ingest_churn`) → `readOnlyHint: false` without `destructiveHint`. HTTP `GET /tools` exposes the same hints. **`initialize` instructions** + resource `codemap://mcp-instructions` carry the tool-selection playbook. **Watcher default-ON since 2026-05** — every tool reads a live index, `audit`'s incremental-index prelude becomes a no-op. Pass `--no-watch` (or `CODEMAP_WATCH=0`) for one-shot fire-and-forget calls without the in-process chokidar loop. +**MCP server (`codemap mcp [--no-watch] [--debounce ]`)** — separate top-level command exposing the structural-query surface (21 JSON-RPC tools — list below) to agent hosts (Claude Code, Cursor, Codex, generic MCP clients) over stdio. Eliminates the bash round-trip on every agent call. Bootstrap once at server boot; each tool returns the same JSON payload its CLI `--json` would print (including `query batch`, `trace`, `explore`, `node`, `file`, `schema`, `symbols`, `context --include-snippets`, `ingest-coverage`, and `ingest-churn`). MCP wraps payloads in `{content: [{type: "text", text: …}]}`. **`tools/list` ToolAnnotations** — advisory `readOnlyHint` / `destructiveHint` / `idempotentHint` per tool: read paths (`query`, `show`, `audit`, …) → `readOnlyHint: true`; apply tools (`apply`, `apply_rows`, `apply_diff_input`) → `destructiveHint: true` (writes still require `yes: true`); index mutators (`save_baseline`, `drop_baseline`, `ingest_coverage`, `ingest_churn`) → `readOnlyHint: false` without `destructiveHint`. HTTP `GET /tools` exposes the same hints. **`initialize` instructions** + resource `codemap://mcp-instructions` carry the tool-selection playbook; after bootstrap they also append **`map_id`** and top hub paths (full routing card via **`context`**). **Watcher default-ON since 2026-05** — every tool reads a live index, `audit`'s incremental-index prelude becomes a no-op. Pass `--no-watch` (or `CODEMAP_WATCH=0`) for one-shot fire-and-forget calls without the in-process chokidar loop. **HTTP server (`codemap serve [--host 127.0.0.1] [--port 7878] [--token ] [--no-watch] [--debounce ]`)** — same tool taxonomy as MCP, exposed over `POST /tool/{name}` for non-MCP consumers (CI scripts, simple `curl`, IDE plugins that don't speak MCP). Loopback-default; any `127.0.0.0/8` bind counts as loopback for the token rule. Bearer-token auth optional on loopback binds and **required** on non-loopback binds (`--host 0.0.0.0`, etc.). HTTP returns each tool's native JSON payload directly (NOT MCP's `{content: [...]}` wrapper); SARIF / annotations / mermaid / diff payloads ship with `application/sarif+json` or `text/plain` Content-Type; `format: "diff-json"` / `"codeclimate"` / `"badge"` + `badge_style: "json"` use `application/json`; badge markdown uses `text/plain`. Resources mirrored at `GET /resources/{encoded-uri}`. `GET /health` is auth-exempt; `GET /tools` / `GET /resources` are catalogs. **Watcher default-ON since 2026-05** — same `--no-watch` / `CODEMAP_WATCH=0` opt-out as `mcp`. @@ -46,7 +46,7 @@ Each emitted delta carries its own `base` metadata so mixed-baseline audits are - **`save_baseline`** — polymorphic `{name, sql? | recipe?}` (exactly one of `sql` / `recipe`). - **`list_baselines`** — no args; returns the array `codemap query --baselines --json` would print. - **`drop_baseline`** — `{name}` → `{dropped}` on success; structured `{error}` on unknown name (MCP sets `isError: true`). -- **`context`** — `{compact?, intent?, include_snippets?}`. CLI: `codemap context [--include-snippets]`. Session-start project envelope with `start_here` shortcuts (one call replaces 4-5 `query`s). `index_summary.file_churn` row count; **`churn_hint`** when empty (steers to index, **`ingest-churn`**, or **`churn.file`**). `include_snippets` adds one-line export previews on hub leaders (capped to adaptive `signature_max_chars`; may set `stale`/`missing`); no-op when `compact: true`. Whitespace-only `intent` is treated as no intent. Prefer `start_here.hub_leaders` over legacy `hubs` for signatures — `hubs` keeps the full bundled `fan-in` recipe limit for backward compatibility. `sample_markers` count scales down on repos >500 / >5000 files. +- **`context`** — `{compact?, intent?, include_snippets?, include_codebase_map?}`. CLI: `codemap context [--include-snippets] [--no-codebase-map]`. Session-start project envelope with `start_here` shortcuts (one call replaces 4-5 `query`s). Non-compact responses also ship **`map_id`** (hash-stable fingerprint) and **`codebase_map`** (hub paths + codemap CLI/MCP routing hints); omit with `compact: true`, `include_codebase_map: false`, or CLI `--no-codebase-map`. `index_summary.file_churn` row count; **`churn_hint`** when empty (steers to index, **`ingest-churn`**, or **`churn.file`**). `include_snippets` adds one-line export previews on hub leaders (capped to adaptive `signature_max_chars`; may set `stale`/`missing`); no-op when `compact: true`. Whitespace-only `intent` is treated as no intent. Prefer `start_here.hub_leaders` over legacy `hubs` for signatures — `hubs` keeps the full bundled `fan-in` recipe limit for backward compatibility. `sample_markers` count scales down on repos >500 / >5000 files. - **`validate`** — `{paths?: string[]}`. SHA-256 vs `files.content_hash`; returns only out-of-sync rows (`stale` / `missing` / `unindexed` / `rejected` — fresh paths are omitted; `rejected` includes optional `reason`: `path escapes project root` | `path escapes via symlink` | `path resolves outside project root`). Output `path` keys are project-relative POSIX paths. - **`show`** — `{name, kind?, in?}` or `{query, with_fts?}`. Exact symbol lookup or field-qualified search (`kind:`, `name:`, `path:`, `in:` + free text) → `{matches, disambiguation?, warning?}`. CLI: `codemap show --query '…' [--print-sql]`. - **`snippet`** — same as `show` (`{name, kind?, in?}` or `{query, with_fts?}`) but each match also carries `source` (file text) + `stale` / `missing` flags → `{matches, disambiguation?, warning?}`. No reindex side-effects.