feat(code-intelligence): provider contract Phase 1 — GBrain, Sourcebot, Graphify behind one ask-once offer

Open a large repo (1,000+ tracked files) and gstack can offer code
intelligence ONCE, with the trade-offs stated: GBrain (semantic memory +
code, sends content to YOUR gbrain DB, per-repo consent), Sourcebot
(self-hosted whole-repo search, local on localhost), Graphify (local
tree-sitter graph, nothing leaves the machine, user-installed), or No
indexing — a decline persists machine-wide so no skill ever asks again.
Small repos never see the question; grep stays the always-working default
and provider-OFF degrades silently (PROVIDER_UNAVAILABLE -> file-only).

Ported: lib/code-intelligence/ (contract + 3 verified adapters + picker +
selection + suggest, MIT headers), the gstack-code-intelligence CLI
(suggest/select/consent/index/search/status), 31 offline tests (fake CLI
shims + injected fetch), and the provider-contract design doc. Verified
live on this repo: suggest fires at 1,233 files with real availability
detail per provider.

Hardened per review: the per-remote trust store is the SINGLE consent
authority — a gstack-gbrain-repo-policy deny tier vetoes any recorded
code-intelligence consent (fail-closed on an unreadable store, pinned by
three tests); both send-capable adapters are registered as fail-closed
MODULE_SINKS in the egress tripwire so a refactor can't drop their
receipts; and local-compute vs remote-send consents are never bundled.
setup-gbrain gains the provider-choice Step 0. The fork's Phases 2-4
glue-collapse is explicitly NOT ported.

Ported from time-attack/gstack (GStack 2); consent unification ours.

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 13:15:03 -07:00
parent 53deeeb116
commit 3aba218303
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
14 changed files with 2183 additions and 0 deletions

206
bin/gstack-code-intelligence Executable file
View File

@ -0,0 +1,206 @@
#!/usr/bin/env bun
/**
* gstack-code-intelligence — pick a code-intelligence provider and use it to
* index and search this repo. OPTIONAL: with nothing selected, gstack works
* fine and callers use grep / the file-only decision store.
*
* Usage:
* gstack-code-intelligence suggest [repo] [--json] # should the one-time indexing offer be made here?
* gstack-code-intelligence options # list providers (GBrain first) + availability
* gstack-code-intelligence status # current selection + availability
* gstack-code-intelligence select <gbrain|sourcebot|graphify|none>
* gstack-code-intelligence consent [repo-path] # allow indexing this repo (per-repo)
* gstack-code-intelligence index [repo-path] # index the repo with the selected provider
* gstack-code-intelligence search <query...> # search via the selected provider
*
* Non-local providers (GBrain, or a Sourcebot on a remote host) refuse to index
* until you consent for that repo. Graphify and a localhost Sourcebot are local:
* nothing leaves the machine, so no consent is needed. Graphify is never
* auto-installed.
*/
import { basename, resolve } from "path";
import {
CodeProviderError,
RECOMMENDED_ORDER,
detectAvailable,
hasConsent,
providerById,
readSelection,
resolveSelectedProvider,
setConsent,
setProvider,
setRoot,
shouldOfferIndexing,
type CodeProviderId,
} from "../lib/code-intelligence";
const PROVIDER_IDS = new Set<CodeProviderId>(["gbrain", "sourcebot", "graphify"]);
const LABEL: Record<CodeProviderId, string> = { gbrain: "GBrain", sourcebot: "Sourcebot", graphify: "Graphify" };
const NOTE: Record<CodeProviderId, string> = {
gbrain: "recommended; federated memory + code (sends content to your GBrain DB)",
sourcebot: "self-hosted whole-repo regex search (local when on localhost)",
graphify: "local tree-sitter code graph, nothing leaves the machine (install it yourself)",
};
function out(s: string): void {
process.stdout.write(`${s}\n`);
}
function fail(s: string): never {
process.stderr.write(`gstack-code-intelligence: ${s}\n`);
process.exit(1);
}
async function cmdOptions(): Promise<void> {
out("Code-intelligence providers (indexing is optional; GBrain recommended):\n");
const avail = await detectAvailable();
const byId = new Map(avail.map((a) => [a.id, a]));
for (const id of RECOMMENDED_ORDER) {
const a = byId.get(id);
const mark = a?.available ? "available" : "not available";
out(` ${id === "gbrain" ? "*" : " "} ${LABEL[id].padEnd(10)} [${mark}] — ${NOTE[id]}`);
if (a?.detail) out(` ${a.detail}`);
}
out("\nSelect one with: gstack-code-intelligence select <provider>");
}
async function cmdStatus(): Promise<void> {
const sel = readSelection();
out(`selected: ${sel.provider ?? "none (grep / file-only fallback)"}`);
const avail = await detectAvailable();
for (const a of avail) out(` ${LABEL[a.id]}: ${a.available ? "available" : "unavailable"} (${a.detail})`);
}
/**
* The one-time session-start offer gate. Prints (or emits as JSON) whether an
* agent should ask the user about indexing this repo, and when it should, the
* provider options with their reasons so the question is self-contained.
*/
async function cmdSuggest(rest: string[]): Promise<void> {
const json = rest.includes("--json");
const pathArg = rest.find((a) => !a.startsWith("--"));
const repoPath = resolve(pathArg ?? process.cwd());
const suggestion = shouldOfferIndexing(repoPath);
if (!suggestion.offer) {
if (json) {
out(JSON.stringify({ ...suggestion, repoPath }));
} else {
out(`no offer (${suggestion.reason}${suggestion.fileCount != null ? `, ${suggestion.fileCount} tracked files` : ""})`);
}
return;
}
const avail = await detectAvailable();
if (json) {
out(JSON.stringify({
...suggestion,
repoPath,
options: avail.map((a) => ({
id: a.id,
label: LABEL[a.id],
reason: NOTE[a.id],
local: providerById(a.id).local,
available: a.available,
detail: a.detail,
})),
}));
return;
}
out(`offer indexing: ${suggestion.fileCount} tracked files (threshold ${suggestion.threshold}) and no prior decision`);
await cmdOptions();
}
function cmdSelect(arg: string | undefined): void {
if (arg === "none") {
setProvider(null);
out("code-intelligence declined; gstack uses grep / file-only fallback and will not ask again");
return;
}
if (!arg || !PROVIDER_IDS.has(arg as CodeProviderId)) {
fail("Usage: select <gbrain|sourcebot|graphify|none>");
}
const id = arg as CodeProviderId;
setProvider(id);
out(`selected ${LABEL[id]}.`);
const provider = providerById(id);
if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`);
}
function cmdConsent(pathArg: string | undefined): void {
const repoPath = resolve(pathArg ?? process.cwd());
setConsent(repoPath, true);
out(`indexing consent recorded for ${repoPath}`);
}
async function cmdIndex(pathArg: string | undefined): Promise<void> {
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first");
const repoPath = resolve(pathArg ?? process.cwd());
const consented = hasConsent(repoPath);
if (!provider!.local && !consented) {
fail(`${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath}\` first.`);
}
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short id.
const sourceId = provider!.id === "graphify" ? repoPath : basename(repoPath);
const repo = { id: sourceId, path: repoPath };
try {
const registered = await provider!.registerSource(repo, { consented });
out(`registered ${repo.id} with ${provider!.label} (${registered.state})`);
const refreshed = await provider!.refresh({ id: registered.id }, { consented });
// Remember which repo this provider indexed so `search` reads the same graph.
setRoot(provider!.id, repoPath);
out(`indexed: ${refreshed.state}${refreshed.itemCount != null ? ` (${refreshed.itemCount} items)` : ""}`);
} catch (err) {
handleProviderError(err, provider!.label);
}
}
async function cmdSearch(terms: string[]): Promise<void> {
const query = terms.join(" ").trim();
if (!query) fail("Usage: search <query...>");
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first (or use grep)");
try {
const hits = await provider!.search(query, { limit: 10 });
if (!hits.length) {
out("(no results)");
return;
}
for (const h of hits) out(`${h.score != null ? `[${h.score.toFixed(2)}] ` : ""}${h.ref}${h.snippet ? ` — ${h.snippet}` : ""}`);
} catch (err) {
handleProviderError(err, provider!.label);
}
}
function handleProviderError(err: unknown, label: string): never {
if (err instanceof CodeProviderError) {
if (err.code === "PROVIDER_UNAVAILABLE") {
fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`);
}
fail(`${label} ${err.code}: ${err.message}`);
}
fail(err instanceof Error ? err.message : String(err));
}
async function main(): Promise<void> {
const [action, ...rest] = process.argv.slice(2);
switch (action) {
case "suggest":
return cmdSuggest(rest);
case "options":
return cmdOptions();
case "status":
return cmdStatus();
case "select":
return cmdSelect(rest[0]);
case "consent":
return cmdConsent(rest[0]);
case "index":
return cmdIndex(rest[0]);
case "search":
return cmdSearch(rest);
default:
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] | index [path] | search <query...>");
}
}
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));

View File

@ -0,0 +1,305 @@
# Code-Intelligence Provider Contract
Status: design + first implementation slice
Owner: maintainer-directed internal work
Related: `runtime/context.js` (Context.dev provider pattern),
`scripts/gstack2/browser-provider-contract.ts` (the existing provider-contract idiom),
`lib/gstack-decision-semantic.ts` (degrade-to-null reliability contract)
## Problem
gstack carries ~17k LOC of home-grown code-intelligence glue: transcript
ingestion (`bin/gstack-memory-ingest.ts`, ~1.9k), a unified sync verb
(`bin/gstack-gbrain-sync.ts`, ~1.6k), context loading
(`bin/gstack-brain-context-load.ts`), a three-tier planning cache
(`bin/gstack-brain-cache`), source reconciliation, engine-status classification,
destructive-op guards, plus ~15 `bin/gstack-gbrain-*` and `bin/gstack-brain-*`
entrypoints and ~40 tests. All of it is bespoke wiring around one external tool
(GBrain) reached by direct CLI shell-out.
We do not want to keep maintaining a home-grown indexer. We want gstack to
define a **small optional contract** that external providers implement, so the
indexing/search/graph work lives in the provider, not in gstack.
Hard requirement, non-negotiable: **gstack must remain fully functional with the
provider OFF.** File-only paths (the decision store, Context Recovery, grep) stay
reliable and never depend on a provider being present. This is the existing
decision-store philosophy (`lib/gstack-decision.ts` has zero gbrain imports;
`lib/gstack-decision-semantic.ts` degrades to `null`). The contract is an
enhancement, never a dependency.
## Design decision: repo-oriented, not document-store
Settled (do not relitigate). The contract is **repo-oriented**:
```
register_source(repo) — required
refresh(source) — required
search(query) — required
status(source) — required
```
`add` / `delete` / `export` are **optional capabilities** a provider MAY
advertise. GBrain advertises them (its native primitive is document-by-slug:
put/delete/get/export); code-search and code-graph tools decline them.
A document-store contract (add / delete-by-id / export as *required* ops) was
rejected: it misrepresents code-search and code-graph tools. Sourcebot indexes a
whole repo and exposes search; it has no concept of "delete document id X".
Forcing every provider to implement a document CRUD surface would either exclude
the exact tools we most want (whole-repo indexers, graph tools) or force them to
stub required ops with lies. Repo-in / query-out is the honest common
denominator. GBrain's document axis survives as an *optional* capability, not as
the contract's shape.
## The contract
TypeScript in `lib/code-intelligence/contract.ts`. Shape (abridged):
```ts
type CodeProviderCapability =
| "register_source" | "refresh" | "search" | "status" // required
| "add" | "delete" | "export"; // optional
interface CodeProvider {
readonly id: "gbrain" | "sourcebot" | "graphify";
readonly label: string;
readonly capabilities: ReadonlySet<CodeProviderCapability>;
readonly local: boolean; // true = no repo content leaves the machine
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
}
```
Every provider MUST implement the four required methods and MUST advertise
exactly the capabilities it backs (`assertRequiredCapabilities` enforces the
required four at construction; a test pins it). Optional methods are present iff
the matching capability is advertised. Calling an unadvertised optional op throws
`CAPABILITY_UNSUPPORTED` — never a silent no-op.
### Typed failures
Mirrors `runtime/context.js`'s `ContextError` discipline (a closed code set,
constructor throws on an unknown code):
| Code | Meaning |
|------|---------|
| `PROVIDER_UNAVAILABLE` | CLI/MCP transport absent — degrade to file-only |
| `PROVIDER_NOT_CONSENTED` | repo indexing not consented and content would leave the machine |
| `CAPABILITY_UNSUPPORTED` | provider declines this op |
| `SOURCE_NOT_REGISTERED` | op needs a source that isn't registered |
| `PROVIDER_TIMEOUT` | provider exceeded the op timeout |
| `PROVIDER_ERROR` | provider ran and failed |
`PROVIDER_UNAVAILABLE` is the load-bearing one: callers catch it (or use the
picker's null resolution) and fall back to grep / file-only. It is never fatal.
### Consent
Two orthogonal consent axes, both explicit, neither auto-granted:
1. **Network / content-egress consent (repo-scoped).** Before any repo content
leaves the machine, indexing must be consented *per repo*. The contract
enforces this in `registerSource`/`refresh`/`add`: when
`provider.local === false` and `opts.consented !== true`, it throws
`PROVIDER_NOT_CONSENTED`. Local providers (Graphify) skip this axis — nothing
leaves the machine.
2. **Install consent (Graphify only).** Graphify is never auto-installed. The
`options`/`status` display marks it available only when its CLI is present,
and nothing in gstack runs a Graphify installer. Install is a user action
(`pip install graphifyy && graphify install`).
This matches the Context.dev model: selection persists without granting egress
consent; egress requires a separate explicit step.
## Per-provider capability matrix
| Op | GBrain (recommend first) | Sourcebot | Graphify |
|----|--------------------------|-----------|----------|
| `register_source` | ✓ `sources add --federated` | ✓ local `git` connection in config.json | ✓ `graphify update <dir>` (local, no LLM) |
| `refresh` | ✓ `sync` + `sync --strategy code --full` | ✓ auto (config change + reindexIntervalMs) | ✓ `graphify update <dir>` |
| `search` | ✓ `gbrain search` (federated corpora) | ✓ `POST /api/search` (keyless w/ anonymous access; Bearer key optional) | ✓ `graphify query "<q>" --graph <graph.json>` |
| `status` | ✓ `sources list` + page_count | ~ partial (server liveness) | ~ partial (graph.json present + node count) |
| `add` | ✓ `put <slug>` | ✗ declines | ✗ declines |
| `delete` | ✓ `delete <slug>` | ✗ declines | ✗ declines |
| `export` | ✓ `export` | ✗ declines | ✓ read `graphify-out/graph.json` |
| `local` (no egress) | no (federated DB) | loopback → **yes**; remote host → no | **yes** (local only) |
All three are driven directly from the runtime — no MCP client:
- **GBrain** (`garrytan/gbrain`, the gstack-ecosystem tool): full contract fit,
driven via the existing `gbrain` CLI chokepoint (`lib/gbrain-exec.ts`). Native
primitive is document-by-slug (put/delete/get/export) PLUS a repo axis
(`sources add`/`sync`). Advertises all seven capabilities. **Recommended
first.**
- **Sourcebot** (`github.com/sourcebot-dev/sourcebot`, YC Fall 2025): self-hosted
whole-repo regex search, deployed via Docker Compose (bundled server + Postgres
+ Redis; no supported non-Docker path). `register_source` adds a local `{ "type":
"git", "url": "file:///path" }` connection to the server's `config.json` (it
re-indexes on config change; a local repo needs a `remote.origin.url` or it is
skipped); `search` is `POST {baseUrl}/api/search`; `status` probes that endpoint.
Declines `add`/`delete`/`export`. It is a **local** tool — indexed code stays on
your machine — and an **API key is optional**: a local instance with anonymous
access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves `/api/search` keyless. The
adapter sends `Authorization: Bearer <SOURCEBOT_API_KEY>` only when a key is set.
A loopback `baseUrl` keeps content on the machine (local=true); a remote one
requires egress consent.
- **Graphify** (`github.com/Graphify-Labs/graphify`, YC-backed): local
tree-sitter code graph via the `graphify` CLI. The adapter uses **`graphify
update <dir>`** — the local, no-LLM build (writes `graphify-out/graph.json`);
it deliberately avoids the bare `graphify <dir>` build, which runs an LLM
extraction backend needing an API key + network. `graphify query "<q>" --graph
<graph.json>` searches it (its `NODE ...`/`EDGE ...` output carries the file at
`src=`/`at=`); `export` reads the graph JSON. Fully local — nothing leaves the
machine. Optional, **install only with explicit user action** (`pip install
graphifyy && graphify install`, needs Python >= 3.10); never auto-installed.
**No local-index option is offered** (deliberately excluded — a naive local
index degrades result quality; we route to a real provider or to file-only grep,
not to a half-baked in-house index).
### Integration surfaces (no MCP needed)
Each provider exposes a runtime-drivable surface, so gstack drives them with a
CLI shell-out or plain HTTP — it never speaks MCP:
- **GBrain / Graphify: CLI.** Shell out (`spawnSync`), same shape and the same
ENOENT→`PROVIDER_UNAVAILABLE` degrade as the existing gbrain glue.
- **Sourcebot: HTTP + a config-file edit.** `POST /api/search` for queries and a
JSON edit of the server's `config.json` to register a repo. `fetch` is
injectable so tests run against a stub, no live server.
Sourcebot and Graphify also ship MCP servers for in-agent use; the contract does
not depend on them, because their CLI/HTTP surfaces are enough to index and
search from the runtime.
## Picker: recommend GBrain first
`lib/code-intelligence/picker.ts` + `selection.ts`. The user picks a provider
with `gstack-code-intelligence select <provider>`, persisted to
`$GSTACK_HOME/code-intelligence.json`. `resolveSelectedProvider()` constructs the
selected provider, or returns `null` when nothing is selected — the provider-OFF
path, where callers degrade to grep / the file-only decision store. Availability
is proven at call time: a selected provider whose CLI/server is absent throws
`PROVIDER_UNAVAILABLE`, which callers catch and degrade on.
`RECOMMENDED_ORDER` is the static **GBrain → Sourcebot → Graphify** fact — GBrain
is always recommended first. `detectAvailable()` probes each provider for the
`options`/`status` display (GBrain via the real `localEngineStatus()`; Graphify
via its CLI/graph presence; Sourcebot via an HTTP liveness probe). The picker
never silently prefers a non-recommended tool.
## How this replaces the current GBrain glue
The contract is the seam; the bespoke glue collapses onto it. Mapping:
| Today (bespoke) | Under the contract |
|-----------------|--------------------|
| `bin/gstack-gbrain-sync.ts` (`sync`/`reindex-code`/`sources`) | `provider.registerSource` / `provider.refresh` |
| `lib/gstack-decision-semantic.ts` `semanticRecall` | `provider.search` (scoped) → same degrade-to-null |
| `bin/gstack-brain-context-load.ts` (`query`/`list_pages`) | `provider.search` / `provider.status` |
| `bin/gstack-memory-ingest.ts` (`import`, put) | `provider.add` (optional cap; GBrain-only) |
| `lib/gbrain-sources.ts` (`ensureSourceRegistered`, `probeSource`) | GBrain adapter internals |
| `lib/gbrain-local-status.ts` | GBrain adapter availability probe (kept, reused) |
| `bin/gstack-gbrain-detect` / `-install` / `-source-wireup` / `-repo-policy` | provider setup + picker + consent (thinner) |
The point is not to delete 17k LOC in one commit — it is to make every consumer
call the contract, then retire the bespoke paths provider-by-provider behind it.
Consumers that only need "search my code, or degrade" stop importing gbrain
specifics entirely.
## Rollout
Phased, each phase independently revertable. Skill-template edits are deferred to
a later phase precisely so the first slices do not trigger the
`gen:gstack2` / parity re-baseline cycle.
- **Phase 1 (this slice): the contract, three real adapters, and a usable CLI.**
`contract.ts` + fully-drivable GBrain (CLI), Graphify (CLI), and Sourcebot
(HTTP + config) adapters + the selection store + the `gstack-code-intelligence`
CLI (`options`/`status`/`select`/`consent`/`index`/`search`) + tests. A user
can select a provider and index/search their repo today. No skill-template or
generated-file changes yet, so no `gen:gstack2` / parity re-baseline.
- **Phase 2: route internal consumers through the contract.** Point
`gstack-decision-semantic` and `gstack-brain-context-load` at
`resolveSelectedProvider()`, preserving degrade-to-null exactly. Behavior-neutral
for the file-only paths.
- **Phase 3: surface selection in the skills.** Offer the picker at the moments a
skill would benefit from indexed search, mirroring the `context` command's
just-in-time consent prompt. Regenerate skills (`bun run gen:gstack2`), re-run
`bun run test:gstack2`, re-baseline parity intentionally.
- **Phase 4: retire bespoke glue.** Once every consumer is on the contract,
delete the sync/ingest/cache entrypoints and their tests provider-by-provider.
## Verified against real environments
All three adapters were driven against the real tools in isolated environments
(parallel agents, one worktree each), and all three now index + search a real repo
end-to-end. Two rounds ran, because the first round's fixes included a mistake that
only real execution caught — recorded here honestly.
- **GBrain — real Postgres+pgvector (Docker), gbrain 0.42.56 — PROVEN.** The
default pglite/WASM engine is broken on macOS (upstream garrytan/gbrain#223), so
the working recipe points gbrain at a real Postgres via `DATABASE_URL`. Real
end-to-end search returned the actual code definition
(`[0.88] src-checksum-ts … export statement computeChecksum`). Real execution
caught a **regression I had introduced**: I removed `--strategy code` from
`refresh` based on a `--help` misread, which silently stopped code from ever
being indexed (only docs were). Restored to the verified two-pass
(`sync`, then `sync --strategy code --full`); `--federated` registration is
load-bearing for global search. Also fixed earlier: engine-down now degrades to
`PROVIDER_UNAVAILABLE` (one-line message) instead of `PROVIDER_ERROR` + a WASM
stack dump.
- **Sourcebot — live v6.5.0 (Docker) — PROVEN keyless.** Endpoint, body, and
response parsing were correct against the real server. Correcting an earlier
wrong conclusion: Sourcebot does **not** require an API key for local use —
enabling anonymous access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves
`/api/search` keyless, verified with a real hit through the CLI with no key set.
The key stays optional; the only fix was messaging (point users to anonymous
access first, key as fallback) plus a note that a local repo needs a
`remote.origin.url` to be indexed. It is a local tool (code stays on the
machine; a boot telemetry ping unless `SOURCEBOT_TELEMETRY_DISABLED=true`).
- **Graphify — real install, graphify 0.9.23 — PROVEN.** Correcting an earlier
wrong claim of mine: for **code**, `graphify <dir>` and `graphify update <dir>`
produce the identical AST graph with **no LLM call**; the LLM only renames
community clusters and ingests non-code docs, adding zero nodes/edges, and our
parser discards the field it touches. So there is deliberately no LLM mode, and
`local=true` is correct. The adapter uses `graphify update`; real `index`+search
returned correct `file:line` refs. Also fixed: `search` now reads the indexed
repo's graph (persisted root), and `options` reports an installed provider as
available.
The larger lesson, kept on the record: a `--help` reading or a single agent's
conclusion is not proof — running the real tool is. It reversed two of my
first-round calls (the gbrain flag removal and the graphify LLM claim).
## What this does NOT change
Per the GStack 2 canonical contract and CLAUDE.md boundaries: no cloud browsers,
no alternate iOS drivers, no local image models, no provider marketplaces, no
workflow engines, **no new state database**. Context.dev remains the only
newly-authorized external service for web context; this contract governs code
intelligence, a separate axis. The existing decision store and Context Recovery
stay file-only and provider-independent.
## Testing
`test/code-intelligence.test.ts` (19 tests, no live tools): capability-matrix
invariants (all providers advertise the four required; only GBrain advertises the
document ops; `local` flags, including loopback-vs-remote Sourcebot); the result
parsers; the selection store + per-repo consent + provider-OFF (`null`); consent
gating (GBrain non-local without consent throws `PROVIDER_NOT_CONSENTED`; local
Graphify is exempt); the GBrain adapter against a fake `gbrain` shim; the Graphify
adapter against a fake `graphify` shim (index builds a graph, search returns hits,
status counts nodes); the Sourcebot adapter against an injected `fetch` + a temp
`config.json` (register writes a local git connection, search maps `files[]` to
hits); and every adapter degrading to `PROVIDER_UNAVAILABLE` when its tool/server
is absent. The `gstack-code-intelligence` CLI was smoke-tested end-to-end:
select → consent gate → local Graphify index (5-node graph) → search.

View File

@ -0,0 +1,187 @@
/**
* code-intelligence/contract the OPTIONAL, repo-oriented provider contract.
*
* gstack does not maintain a home-grown indexer. It defines this small contract
* and external providers (GBrain, Sourcebot, Graphify) implement it. The whole
* contract is OPTIONAL: when no provider is available/consented,
* `resolveCodeProvider()` returns null and callers degrade to grep / the
* file-only decision store. Never a dependency, always an enhancement the same
* reliability contract as lib/gstack-decision-semantic.ts.
*
* Repo-oriented, not document-store (settled): register_source / refresh /
* search / status are required; add / delete / export are optional capabilities
* a provider MAY advertise. A document-CRUD-required contract would misrepresent
* whole-repo code-search and code-graph tools. See
* docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
*/
export type CodeProviderId = "gbrain" | "sourcebot" | "graphify";
export type CodeProviderCapability =
| "register_source"
| "refresh"
| "search"
| "status"
| "add"
| "delete"
| "export";
export const REQUIRED_CAPABILITIES: readonly CodeProviderCapability[] = [
"register_source",
"refresh",
"search",
"status",
] as const;
export const OPTIONAL_CAPABILITIES: readonly CodeProviderCapability[] = [
"add",
"delete",
"export",
] as const;
export interface RepoRef {
/** Source id the provider registers this repo under. */
id: string;
/** Local worktree path. */
path: string;
/** Remote URL, when the provider clones/manages it. */
remoteUrl?: string;
}
export interface SourceRef {
id: string;
}
export interface SourceStatus {
id: string;
state: "registered" | "indexing" | "ready" | "absent" | "unknown";
/** Pages / files / graph nodes, when the provider reports a count. */
itemCount?: number;
detail?: string;
/** True when the provider only implements a partial status probe. */
partial?: boolean;
}
export interface CodeSearchHit {
/** Slug, file path, or symbol id — whatever the provider keys results on. */
ref: string;
score?: number;
snippet?: string;
kind?: "document" | "file" | "symbol" | "graph-node";
}
export interface OpOptions {
/**
* Env override for spawned processes. Production callers leave this unset;
* tests inject a synthetic env (fake CLI on PATH). Matches the existing
* gbrain helpers.
*/
env?: NodeJS.ProcessEnv;
/** Timeout in ms for the underlying op. */
timeout?: number;
/**
* Explicit per-repo consent that repo content may leave the machine. Required
* for non-local providers on register_source / refresh / add.
*/
consented?: boolean;
}
export interface SearchOptions extends OpOptions {
/** Restrict to a registered source. */
source?: string;
limit?: number;
minScore?: number;
}
export interface CodeProvider {
readonly id: CodeProviderId;
readonly label: string;
readonly capabilities: ReadonlySet<CodeProviderCapability>;
/** True when no repo content leaves the machine (Graphify). */
readonly local: boolean;
has(capability: CodeProviderCapability): boolean;
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
}
export const CODE_PROVIDER_FAILURES = Object.freeze([
"PROVIDER_UNAVAILABLE",
"PROVIDER_NOT_CONSENTED",
"CAPABILITY_UNSUPPORTED",
"SOURCE_NOT_REGISTERED",
"PROVIDER_TIMEOUT",
"PROVIDER_ERROR",
] as const);
export type CodeProviderFailure = (typeof CODE_PROVIDER_FAILURES)[number];
const FAILURE_SET = new Set<string>(CODE_PROVIDER_FAILURES);
/**
* Typed provider failure. Mirrors runtime/context.js ContextError discipline:
* the code set is closed and the constructor throws on an unknown code, so a
* typo can never mint an untyped failure.
*/
export class CodeProviderError extends Error {
readonly code: CodeProviderFailure;
readonly providerId?: CodeProviderId;
constructor(code: CodeProviderFailure, message: string, providerId?: CodeProviderId) {
if (!FAILURE_SET.has(code)) throw new TypeError(`Unknown code-provider failure code: ${code}`);
super(message);
this.name = "CodeProviderError";
this.code = code;
this.providerId = providerId;
}
}
/**
* Enforce that a provider advertises every required capability. Called by each
* adapter constructor so an incomplete provider fails fast, not at first search.
*/
export function assertRequiredCapabilities(
id: CodeProviderId,
capabilities: ReadonlySet<CodeProviderCapability>,
): void {
const missing = REQUIRED_CAPABILITIES.filter((cap) => !capabilities.has(cap));
if (missing.length) {
throw new TypeError(`Code provider ${id} is missing required capabilities: ${missing.join(", ")}`);
}
}
/**
* Guard for optional ops: throw CAPABILITY_UNSUPPORTED (never a silent no-op)
* when a provider is asked for a capability it does not advertise.
*/
export function assertCapability(provider: CodeProvider, capability: CodeProviderCapability): void {
if (!provider.has(capability)) {
throw new CodeProviderError(
"CAPABILITY_UNSUPPORTED",
`${provider.label} does not support "${capability}"`,
provider.id,
);
}
}
/**
* Repo-scoped egress consent gate. Non-local providers must not move repo
* content off the machine without explicit per-repo consent. Local providers
* (nothing leaves the machine) are exempt.
*/
export function assertEgressConsent(provider: CodeProvider, opts?: OpOptions): void {
if (provider.local) return;
if (opts?.consented === true) return;
throw new CodeProviderError(
"PROVIDER_NOT_CONSENTED",
`${provider.label} would send repo content off this machine; per-repo indexing consent is required`,
provider.id,
);
}

View File

@ -0,0 +1,246 @@
/**
* GBrain adapter full contract fit over the existing gbrain CLI chokepoint.
*
* Reuses lib/gbrain-exec.ts (spawnGbrain, seeded DATABASE_URL) and
* lib/gbrain-sources.ts (ensureSourceRegistered, probeSource, sourcePageCount)
* rather than re-issuing raw commands, so the DATABASE_URL / GBRAIN_HOME /
* Windows-shim guarantees carry over unchanged. GBrain's native primitive is
* document-by-slug (put/delete/get/export) PLUS a repo axis (sources add/sync),
* so it advertises all seven capabilities.
*/
import { spawnSync } from "child_process";
import { sha256Hex, writeReceipt } from "../egress-receipt.js";
import { spawnGbrain, buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "../gbrain-exec";
import { ensureSourceRegistered, probeSource, sourcePageCount } from "../gbrain-sources";
import {
assertCapability,
assertEgressConsent,
assertRequiredCapabilities,
CodeProviderError,
type CodeProvider,
type CodeProviderCapability,
type CodeSearchHit,
type OpOptions,
type RepoRef,
type SearchOptions,
type SourceRef,
type SourceStatus,
} from "./contract";
const CAPABILITIES: CodeProviderCapability[] = [
"register_source",
"refresh",
"search",
"status",
"add",
"delete",
"export",
];
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* Parse `gbrain search` text output (`[score] slug -- snippet`) into hits.
* gbrain's search prints text, not JSON (verified in
* lib/gstack-decision-semantic.ts). Exported for deterministic unit testing.
*/
export function parseGbrainSearch(stdout: string, minScore: number, limit: number): CodeSearchHit[] {
const hits: CodeSearchHit[] = [];
for (const line of stdout.split("\n")) {
const m = line.match(/^\[([\d.]+)\]\s+(\S+)\s+--\s+(.*)$/);
if (!m) continue;
const score = parseFloat(m[1]);
if (!Number.isFinite(score) || score < minScore) continue;
hits.push({ ref: m[2], score, snippet: m[3].trim(), kind: "document" });
}
return hits.slice(0, limit);
}
export class GbrainProvider implements CodeProvider {
readonly id = "gbrain" as const;
readonly label = "GBrain";
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
/** GBrain federates into a (possibly remote) DB, so content can leave the machine. */
readonly local = false;
constructor() {
assertRequiredCapabilities(this.id, this.capabilities);
}
has(capability: CodeProviderCapability): boolean {
return this.capabilities.has(capability);
}
/**
* Fail-closed egress receipt for the write ops (register/refresh/add). The
* gbrain subprocess owns the wire bytes, so the receipt records destination
* + payload class; sha256 is only known for `add` (the exact document body).
*/
#receipt(payloadClass: string, opts: OpOptions, body?: string): void {
writeReceipt({
env: opts.env,
sink: "gbrain",
host: "gbrain-db (user-configured DATABASE_URL)",
payloadClass,
bytes: body == null ? 0 : Buffer.byteLength(body),
sha256: body == null ? null : sha256Hex(body),
consent: "code-intelligence provider=gbrain + per-repo consented=true",
});
}
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts);
this.#receipt("repo-source-registration (sent by gbrain subprocess)", opts);
try {
const result = await ensureSourceRegistered(repo.id, repo.path, {
federated: true,
env: opts.env,
});
return {
id: repo.id,
state: result.state.status === "match" ? "registered" : "unknown",
detail: result.changed ? "registered" : "already registered",
};
} catch (err) {
throw this.#wrap(err);
}
}
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts);
this.#receipt("repo-code-index (sent by gbrain subprocess)", opts);
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
// Two passes, verified end-to-end against real Postgres-backed gbrain 0.42.56:
// 1. default sync (markdown strategy) — indexes docs.
// 2. `sync --strategy code` — the ACTUAL code-indexing pass. Without it code
// is never indexed (the whole point of a code provider); `code-def` stays
// "not_built" and search only finds incidental doc mentions. `--full`
// forces it past the per-source checkpoint the markdown pass advanced.
this.#assertOk(spawnGbrain(["sync", "--source", source.id], { baseEnv: opts.env, timeout }));
this.#assertOk(spawnGbrain(["sync", "--source", source.id, "--strategy", "code", "--full"], { baseEnv: opts.env, timeout }));
return this.status(source, opts);
}
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
// `gbrain search` is global and has no `--source` flag; `--limit` is real
// (verified against gbrain 0.42.x --help).
const args = ["search", query];
if (opts.limit) args.push("--limit", String(opts.limit));
const r = spawnGbrain(args, { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS });
this.#assertOk(r);
return parseGbrainSearch(r.stdout || "", opts.minScore ?? 0.1, opts.limit ?? 10);
}
async status(source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
if (!source) {
// No source given: liveness probe. `sources list` reachable = ready.
this.#assertOk(spawnGbrain(["sources", "list", "--json"], {
baseEnv: opts.env,
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
}));
return { id: "*", state: "ready" };
}
try {
const probed = probeSource(source.id, opts.env);
if (probed.status === "absent") return { id: source.id, state: "absent" };
const count = sourcePageCount(source.id, opts.env);
return {
id: source.id,
state: "ready",
itemCount: count ?? undefined,
detail: probed.registered_path,
};
} catch (err) {
throw this.#wrap(err);
}
}
// Document ops (add/delete/export) are GBrain-only and secondary; they match
// gbrain's documented CLI surface (`put <slug>` reads stdin; `delete <slug>`;
// `export`) but could not be exercised against a live engine on the test host
// (pglite WASM broken, garrytan/gbrain#223), so treat them as best-effort.
async add(doc: { slug: string; body: string }, opts: OpOptions = {}): Promise<SourceStatus> {
assertCapability(this, "add");
assertEgressConsent(this, opts);
this.#receipt("document-body (sent by gbrain subprocess)", opts, doc.body);
// `gbrain put <slug>` reads the document body from stdin.
this.#assertOk(this.#runInput(["put", doc.slug], doc.body, opts));
return { id: doc.slug, state: "ready" };
}
async delete(slug: string, opts: OpOptions = {}): Promise<SourceStatus> {
assertCapability(this, "delete");
// stdin closed ("") so any confirmation prompt gets EOF rather than hanging.
this.#assertOk(this.#runInput(["delete", slug], "", opts));
return { id: slug, state: "absent" };
}
async export(_source: SourceRef, opts: OpOptions = {}): Promise<string> {
assertCapability(this, "export");
// `gbrain export` is brain-wide (no per-source flag); returns whatever it prints.
const r = spawnGbrain(["export"], {
baseEnv: opts.env,
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
});
this.#assertOk(r);
return r.stdout || "";
}
/** spawn gbrain with `input` on stdin, seeded env, Windows-shim aware. */
#runInput(args: string[], input: string, opts: OpOptions) {
return spawnSync("gbrain", args, {
input,
encoding: "utf-8",
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
env: buildGbrainEnv({ baseEnv: opts.env }),
shell: NEEDS_SHELL_ON_WINDOWS,
});
}
/**
* Throw a typed failure unless the spawn succeeded. Distinguishes a missing
* CLI (ENOENT PROVIDER_UNAVAILABLE, the degrade signal) from a timeout
* (ETIMEDOUT/SIGTERM, status=null) and a real non-zero exit.
*/
#assertOk(r: {
status: number | null;
stderr?: string;
error?: Error & { code?: string };
signal?: NodeJS.Signals | null;
}): void {
if (r.status === 0) return;
const stderr = (r.stderr || "").trim();
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "gbrain CLI not on PATH", this.id);
}
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
}
// Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE
// (caller falls back to file-only), not a hard PROVIDER_ERROR with a raw dump.
// Covers the real case where gbrain's pglite engine fails to init its WASM
// runtime (garrytan/gbrain#223) as well as unreachable/unconfigured databases.
if (/PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(stderr) || "gbrain engine unavailable", this.id);
}
throw new CodeProviderError("PROVIDER_ERROR", firstLine(stderr) || `gbrain exited ${r.status}`, this.id);
}
#wrap(err: unknown): CodeProviderError {
if (err instanceof CodeProviderError) return err;
const message = err instanceof Error ? err.message : String(err);
// Same environmental-vs-real split as #assertOk: missing CLI, or engine/DB/
// config problems, degrade to UNAVAILABLE so callers fall back to file-only.
if (/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json/i.test(message)) {
return new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(message), this.id);
}
return new CodeProviderError("PROVIDER_ERROR", firstLine(message), this.id);
}
}
/** First non-empty line, so a multi-line WASM/stack dump never reaches the user. */
function firstLine(text: string): string {
return (text || "").split("\n").map((l) => l.trim()).find(Boolean) ?? "";
}

View File

@ -0,0 +1,184 @@
/**
* Graphify adapter real CLI integration (github.com/Graphify-Labs/graphify).
*
* Graphify is a LOCAL tree-sitter knowledge graph. For CODE, `graphify <dir>`
* and `graphify update <dir>` produce the SAME AST graph with NO LLM and NO
* network (verified against graphify 0.9.23 both emit `AST extraction on N
* code files`, all node origins `ast`). The LLM backend (openai/gemini) is only
* used to RENAME community clusters (`graphify label` / `cluster-only`) and to
* ingest non-code docs (`graphify add`); it adds zero nodes/edges, and our parser
* discards the `community=` field it touches so an LLM mode would send code
* off-machine for no change in search output, and is intentionally not offered.
*
* This adapter uses `graphify update <dir>` (writes `<dir>/graphify-out/graph.json`
* and does clustering in one shot) and stays fully local nothing leaves the
* machine, so `local = true` and no egress consent is needed.
*
* Query is `graphify query "<q>" --graph <dir>/graphify-out/graph.json`; the
* `--graph` flag points at the built graph so search never depends on cwd.
*
* Never auto-installed: install is `pip install graphifyy && graphify install`
* (needs Python >= 3.10), a user action the picker surfaces. When the CLI is
* absent every op throws PROVIDER_UNAVAILABLE and callers degrade to file-only.
*
* Path-based, not id-based: for Graphify a source "id" IS the absolute repo path
* (that is where `graphify-out/` lives), unlike GBrain's short source ids.
*/
import { spawnSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import {
assertCapability,
assertRequiredCapabilities,
CodeProviderError,
type CodeProvider,
type CodeProviderCapability,
type CodeSearchHit,
type OpOptions,
type RepoRef,
type SearchOptions,
type SourceRef,
type SourceStatus,
} from "./contract";
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status", "export"];
const OUT_DIR = "graphify-out";
const GRAPH_JSON = "graph.json";
const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while
const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows
export interface GraphifyOptions {
/** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */
root?: string;
env?: NodeJS.ProcessEnv;
}
export class GraphifyProvider implements CodeProvider {
readonly id = "graphify" as const;
readonly label = "Graphify";
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
/** Fully local — no repo content leaves the machine. */
readonly local = true;
readonly #root: string;
readonly #env?: NodeJS.ProcessEnv;
constructor(opts: GraphifyOptions = {}) {
this.#root = opts.root ?? process.cwd();
this.#env = opts.env;
assertRequiredCapabilities(this.id, this.capabilities);
}
has(capability: CodeProviderCapability): boolean {
return this.capabilities.has(capability);
}
#run(args: string[], cwd: string, timeout: number) {
return spawnSync("graphify", args, {
cwd,
encoding: "utf-8",
timeout,
stdio: ["ignore", "pipe", "pipe"],
env: this.#env,
shell: NEEDS_SHELL_ON_WINDOWS,
});
}
#assertOk(r: { status: number | null; stderr?: string; error?: Error & { code?: string }; signal?: NodeJS.Signals | null }): void {
if (r.status === 0) return;
const stderr = (r.stderr || "").trim();
if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", "graphify CLI not on PATH (install: pip install graphifyy && graphify install)", this.id);
}
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
throw new CodeProviderError("PROVIDER_TIMEOUT", "graphify timed out", this.id);
}
throw new CodeProviderError("PROVIDER_ERROR", stderr || `graphify exited ${r.status}`, this.id);
}
/** Build the graph over repo.path locally (no LLM, no egress consent needed). */
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
this.#assertOk(this.#run(["update", repo.path], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS));
return this.status({ id: repo.path }, opts);
}
/** Re-parse and rebuild the graph (same local `graphify update` path). */
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
this.#assertOk(this.#run(["update", source.id], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS));
return this.status(source, opts);
}
/**
* `graphify query "<q>" --graph <graph.json>` traces the graph and prints
* `NODE ...` / `EDGE ...` lines (plus a `Traversal:` header). We pass `--graph`
* explicitly so the query reads the indexed repo's graph regardless of cwd.
*/
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
const root = opts.source ?? this.#root;
const graphPath = join(root, OUT_DIR, GRAPH_JSON);
const r = this.#run(["query", query, "--graph", graphPath], root, opts.timeout ?? DEFAULT_TIMEOUT_MS);
this.#assertOk(r);
return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10);
}
async status(source?: SourceRef, _opts: OpOptions = {}): Promise<SourceStatus> {
const dir = source?.id ?? this.#root;
const graphPath = join(dir, OUT_DIR, GRAPH_JSON);
if (!existsSync(graphPath)) return { id: dir, state: "absent" };
let itemCount: number | undefined;
try {
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
} catch {
// graph.json present but unparseable — still ready, just no count.
}
return { id: dir, state: "ready", itemCount, detail: graphPath };
}
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {
assertCapability(this, "export");
const graphPath = join(source.id, OUT_DIR, GRAPH_JSON);
if (!existsSync(graphPath)) {
throw new CodeProviderError("SOURCE_NOT_REGISTERED", `no graph at ${graphPath}; index it first`, this.id);
}
return readFileSync(graphPath, "utf-8");
}
}
/**
* Parse real `graphify query` output into hits. The format (graphify 0.9.23):
* Traversal: BFS depth=2 | Start: ['query()'] | ... | 4 nodes found
* NODE query() [src=db.py loc=L4 community=login]
* EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8
* The file lives mid-line (`src=<file> loc=L<n>` on NODE, `at=<file>:L<n>` on
* EDGE), so the ref is `<file>:L<n>`. The `Traversal:` header and any other line
* are skipped. Exported for deterministic unit testing against the real format.
*/
export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] {
const hits: CodeSearchHit[] = [];
for (const raw of stdout.split("\n")) {
const line = raw.trim();
let ref: string | undefined;
const node = line.match(/^NODE\b.*?\[src=(\S+)\s+loc=(L\d+)/);
const edge = line.match(/^EDGE\b.*?\bat=(\S+?):(L\d+)\b/);
if (node) ref = `${node[1]}:${node[2]}`;
else if (edge) ref = `${edge[1]}:${edge[2]}`;
else continue; // skip the Traversal header and anything non-NODE/EDGE
hits.push({ ref, snippet: line, kind: "graph-node" });
if (hits.length >= limit) break;
}
return hits;
}
/** Whether the `graphify` CLI is installed (for the picker's availability probe). */
export function graphifyInstalled(env?: NodeJS.ProcessEnv): boolean {
const r = spawnSync("graphify", ["--version"], {
encoding: "utf-8",
timeout: 5_000,
stdio: ["ignore", "ignore", "ignore"],
env,
shell: NEEDS_SHELL_ON_WINDOWS,
});
return r.status === 0;
}

View File

@ -0,0 +1,33 @@
/**
* code-intelligence the OPTIONAL, repo-oriented provider contract.
* See docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
*/
export * from "./contract";
export { GbrainProvider, parseGbrainSearch } from "./gbrain-adapter";
export { GraphifyProvider, parseGraphifyQuery, type GraphifyOptions } from "./graphify-adapter";
export { SourcebotProvider, parseSourcebotSearch, type SourcebotOptions } from "./sourcebot-adapter";
export {
readSelection,
setProvider,
setConsent,
hasConsent,
setRoot,
getRoot,
type Selection,
} from "./selection";
export {
LARGE_REPO_FILE_THRESHOLD,
shouldOfferIndexing,
trackedFileCount,
type Suggestion,
type SuggestReason,
} from "./suggest";
export {
RECOMMENDED_ORDER,
providerById,
resolveSelectedProvider,
detectAvailable,
type PickerOptions,
type Availability,
} from "./picker";

View File

@ -0,0 +1,100 @@
/**
* Picker constructs the code-intelligence provider the user selected, and
* offers the recommendation order (GBrain first) for the selection UX.
*
* `resolveSelectedProvider()` reads the persisted selection and constructs that
* provider, or returns null when nothing is selected the provider-OFF path,
* where callers degrade to grep / the file-only decision store. Availability is
* proven at call time: a selected provider whose tool/server is absent throws
* PROVIDER_UNAVAILABLE from its ops, which callers catch and degrade on. The
* `detectAvailable()` probe drives the `options`/`status` display.
*
* GBrain is recommended first. Graphify is NEVER auto-installed it appears in
* the options only once its CLI is present (a user install).
*/
import { localEngineStatus } from "../gbrain-local-status";
import { GbrainProvider } from "./gbrain-adapter";
import { GraphifyProvider, graphifyInstalled, type GraphifyOptions } from "./graphify-adapter";
import { SourcebotProvider, type SourcebotOptions } from "./sourcebot-adapter";
import { readSelection, getRoot } from "./selection";
import type { CodeProvider, CodeProviderId } from "./contract";
/** Recommendation order — GBrain first. */
export const RECOMMENDED_ORDER: readonly CodeProviderId[] = ["gbrain", "sourcebot", "graphify"];
export interface PickerOptions {
env?: NodeJS.ProcessEnv;
graphify?: GraphifyOptions;
sourcebot?: SourcebotOptions;
}
/** Construct a provider by id (no availability check — ops degrade at call time). */
export function providerById(id: CodeProviderId, opts: PickerOptions = {}): CodeProvider {
switch (id) {
case "gbrain":
return new GbrainProvider();
case "graphify": {
// Default the graph root to the repo Graphify last indexed, so `search`
// reads the same graph `index` built (not whatever cwd happens to be).
const root = opts.graphify?.root ?? getRoot("graphify", opts.env);
return new GraphifyProvider({ env: opts.env, ...opts.graphify, ...(root ? { root } : {}) });
}
case "sourcebot":
return new SourcebotProvider({ env: opts.env, ...opts.sourcebot });
}
}
/**
* The provider the user selected, constructed, or null when none is selected.
* Null is the provider-OFF path: callers MUST degrade to grep / file-only.
*/
export function resolveSelectedProvider(opts: PickerOptions = {}): CodeProvider | null {
const { provider } = readSelection(opts.env);
return provider ? providerById(provider, opts) : null;
}
export interface Availability {
id: CodeProviderId;
available: boolean;
detail: string;
}
/**
* Probe which providers are usable right now, in recommendation order. Used by
* the `options`/`status` display. GBrain via the real localEngineStatus();
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe.
*/
export async function detectAvailable(opts: PickerOptions = {}): Promise<Availability[]> {
const gbrainStatus = localEngineStatus({ env: opts.env });
const gbrainOk = gbrainStatus === "ok" || gbrainStatus === "timeout";
// Available = the CLI is installed and selectable (NOT "a graph already exists
// here"). A freshly installed Graphify with no graph yet is still available.
const graphifyOk = graphifyInstalled(opts.env);
let graphifyDetail = "graphify CLI not installed (pip install graphifyy, Python >= 3.10)";
if (graphifyOk) {
try {
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status();
graphifyDetail = s.state === "ready" ? "installed; graph built in this repo" : "installed; run `index` to build a graph";
} catch {
graphifyDetail = "installed";
}
}
let sourcebotOk = false;
let sourcebotDetail = "server unreachable";
try {
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status();
sourcebotOk = s.state === "ready";
sourcebotDetail = s.detail ?? "";
} catch {
sourcebotOk = false;
}
return [
{ id: "gbrain", available: gbrainOk, detail: `gbrain engine: ${gbrainStatus}` },
{ id: "sourcebot", available: sourcebotOk, detail: sourcebotDetail },
{ id: "graphify", available: graphifyOk, detail: graphifyDetail },
];
}

View File

@ -0,0 +1,119 @@
/**
* selection persists the user's chosen code-intelligence provider and their
* per-repo indexing consent. Stored at `$GSTACK_HOME/code-intelligence.json`
* (default `~/.gstack/`), the same home the rest of gstack uses.
*
* Consent is per-repo (keyed by absolute repo path), because indexing consent
* is "may THIS repo's content be indexed by the selected provider" a decision
* a user makes per project, not once for the machine. No selection at all is the
* provider-OFF default: callers degrade to grep / the file-only decision store.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join, resolve } from "path";
import { execFileSync, spawnSync } from "child_process";
import type { CodeProviderId } from "./contract";
export interface Selection {
provider: CodeProviderId | null;
/** Absolute repo path → consented. */
consents: Record<string, boolean>;
/** Provider id → the absolute repo path it last indexed (so search finds it). */
roots: Record<string, string>;
/** User explicitly chose no indexing — never offer again. */
declined: boolean;
}
const EMPTY: Selection = { provider: null, consents: {}, roots: {}, declined: false };
function storePath(env: NodeJS.ProcessEnv = process.env): string {
const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack");
return join(home, "code-intelligence.json");
}
export function readSelection(env: NodeJS.ProcessEnv = process.env): Selection {
const p = storePath(env);
if (!existsSync(p)) return { ...EMPTY };
try {
const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial<Selection>;
return {
provider: raw.provider ?? null,
consents: raw.consents && typeof raw.consents === "object" ? raw.consents : {},
roots: raw.roots && typeof raw.roots === "object" ? raw.roots : {},
declined: raw.declined === true,
};
} catch {
return { ...EMPTY };
}
}
function write(selection: Selection, env: NodeJS.ProcessEnv = process.env): void {
const p = storePath(env);
mkdirSync(dirname(p), { recursive: true });
const tmp = `${p}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(selection, null, 2), "utf-8");
renameSync(tmp, p);
}
export function setProvider(provider: CodeProviderId | null, env: NodeJS.ProcessEnv = process.env): Selection {
// Choosing a provider clears a prior decline; clearing to null records one,
// so the session-start offer is never repeated after an explicit "none".
const next = { ...readSelection(env), provider, declined: provider === null };
write(next, env);
return next;
}
/** Record per-repo indexing consent (repo path resolved to absolute). */
export function setConsent(repoPath: string, consented: boolean, env: NodeJS.ProcessEnv = process.env): Selection {
const current = readSelection(env);
const next: Selection = { ...current, consents: { ...current.consents, [resolve(repoPath)]: consented } };
write(next, env);
return next;
}
/**
* The per-remote trust store (gstack-gbrain-repo-policy) is the SINGLE
* authority for consent-to-send: a `deny` tier vetoes any recorded
* code-intelligence consent, so two stores can never disagree about whether
* code may leave this repo (R1, fork port wave 2 review). Mirrors the
* gbrain-sync chokepoint's polarity: no policy store no veto (nothing was
* ever set); unreadable store veto (fail-closed a policy the user set
* must not be bypassed by a broken store).
*/
function repoPolicyVeto(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
const home = env.GSTACK_HOME || join(homedir(), ".gstack");
if (!existsSync(join(home, "gbrain-repo-policy.json"))) return false;
let url = "";
try {
url = execFileSync("git", ["-C", resolve(repoPath), "remote", "get-url", "origin"], {
encoding: "utf-8", timeout: 5000,
}).trim();
} catch {
return false; // no remote → policy (keyed by remote) has nothing set for this repo
}
if (!url) return false;
const res = spawnSync(join(import.meta.dir, "..", "..", "bin", "gstack-gbrain-repo-policy"), ["get", url], {
encoding: "utf-8", timeout: 10_000, env: { ...env } as NodeJS.ProcessEnv,
});
if (res.error || res.status !== 0) return true; // fail-closed
const tier = (res.stdout || "").trim();
return tier === "deny";
}
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
if (readSelection(env).consents[resolve(repoPath)] !== true) return false;
return !repoPolicyVeto(repoPath, env);
}
/** Record the repo path a provider last indexed, so search reads the same graph. */
export function setRoot(provider: CodeProviderId, repoPath: string, env: NodeJS.ProcessEnv = process.env): Selection {
const current = readSelection(env);
const next: Selection = { ...current, roots: { ...current.roots, [provider]: resolve(repoPath) } };
write(next, env);
return next;
}
export function getRoot(provider: CodeProviderId, env: NodeJS.ProcessEnv = process.env): string | undefined {
return readSelection(env).roots[provider];
}

View File

@ -0,0 +1,243 @@
/**
* Sourcebot adapter real HTTP + config integration
* (github.com/sourcebot-dev/sourcebot, YC F2025).
*
* Sourcebot is a self-hosted server that indexes repos declared in its
* config.json and serves regex code search over `POST /api/search` (zoekt). So
* the runtime drives it with plain HTTP + a config-file edit no MCP:
* - register_source: add `{ "type": "git", "url": "file:///abs/path" }` to the
* server's config.json (it re-indexes automatically on config change).
* - refresh: Sourcebot re-indexes on config change and on reindexIntervalMs;
* there is no per-source trigger endpoint, so refresh reports current status.
* - search: POST /api/search with a regex query, map files[] to hits.
* - status: liveness GET against the base URL.
* Declines the document ops (add/delete/export) it is a whole-repo search index.
*
* Egress: a loopback base URL means the index runs on this machine, so no repo
* content leaves it (local=true). A non-loopback base URL means content reaches
* another host, so egress consent is required (local=false).
*/
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
import { sha256Hex, writeReceipt } from "../egress-receipt.js";
import {
assertCapability,
assertEgressConsent,
assertRequiredCapabilities,
CodeProviderError,
type CodeProvider,
type CodeProviderCapability,
type CodeSearchHit,
type OpOptions,
type RepoRef,
type SearchOptions,
type SourceRef,
type SourceStatus,
} from "./contract";
const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status"];
const DEFAULT_URL = "http://localhost:3000";
const DEFAULT_TIMEOUT_MS = 30_000;
type FetchLike = typeof globalThis.fetch;
export interface SourcebotOptions {
/** Base URL of the Sourcebot server. Defaults to SOURCEBOT_URL or http://localhost:3000. */
baseUrl?: string;
/** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */
configPath?: string;
/**
* OPTIONAL API key for the Sourcebot REST API. Defaults to SOURCEBOT_API_KEY.
* A local instance with anonymous access enabled
* (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves `/api/search` with NO key
* verified keyless against a real Sourcebot v6.5.0. Only set a key when your
* instance is login-gated; it is sent as `Authorization: Bearer <key>`.
*/
apiKey?: string;
/** Injectable fetch for tests. */
fetch?: FetchLike;
env?: NodeJS.ProcessEnv;
}
function isLoopback(url: string): boolean {
try {
const h = new URL(url).hostname.toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
} catch {
return false;
}
}
export class SourcebotProvider implements CodeProvider {
readonly id = "sourcebot" as const;
readonly label = "Sourcebot";
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
readonly local: boolean;
readonly #baseUrl: string;
readonly #configPath?: string;
readonly #apiKey?: string;
readonly #fetch: FetchLike;
constructor(opts: SourcebotOptions = {}) {
const env = opts.env ?? process.env;
this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, "");
this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG;
this.#apiKey = opts.apiKey ?? env.SOURCEBOT_API_KEY;
this.#fetch = opts.fetch ?? globalThis.fetch;
this.local = isLoopback(this.#baseUrl);
assertRequiredCapabilities(this.id, this.capabilities);
}
#authHeaders(): Record<string, string> {
return this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {};
}
has(capability: CodeProviderCapability): boolean {
return this.capabilities.has(capability);
}
/**
* Add the repo as a local `git` connection in Sourcebot's config.json.
* NOTE: Sourcebot silently SKIPS a local repo that has no `remote.origin.url`
* (logs "Skipping <path> - remote.origin.url not found"); a freshly `git init`'d
* repo must set an origin before it will index.
*/
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts); // no-op when the server is loopback (local)
if (!this.#configPath) {
throw new CodeProviderError(
"PROVIDER_UNAVAILABLE",
"set SOURCEBOT_CONFIG to the server's config.json path to register sources",
this.id,
);
}
let config: { connections?: Record<string, unknown> };
try {
config = existsSync(this.#configPath)
? (JSON.parse(readFileSync(this.#configPath, "utf-8")) as typeof config)
: {};
} catch (err) {
throw new CodeProviderError("PROVIDER_ERROR", `unreadable Sourcebot config: ${(err as Error).message}`, this.id);
}
config.connections = config.connections ?? {};
config.connections[repo.id] = { type: "git", url: `file://${repo.path}` };
// Atomic write so a running Sourcebot never reads a half-written config.
const tmp = `${this.#configPath}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(config, null, 2), "utf-8");
renameSync(tmp, this.#configPath);
return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" };
}
/** Sourcebot re-indexes automatically; report current liveness. */
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
const live = await this.status(source, opts);
return { ...live, detail: "Sourcebot re-indexes automatically (config change + reindexIntervalMs)" };
}
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
const body = {
query: opts.source ? `repo:${opts.source} ${query}` : query,
matches: opts.limit ?? 20,
isRegexEnabled: true,
isCaseSensitivityEnabled: false,
};
const payload = await this.#post("/api/search", body, opts.timeout ?? DEFAULT_TIMEOUT_MS);
return parseSourcebotSearch(payload, opts.limit ?? 20);
}
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
// `redirect: manual` so an auth-gated server (307 -> /login) reads as
// not-usable instead of following to a 200 and falsely reporting "ready".
try {
const res = await this.#fetchWithTimeout(
`${this.#baseUrl}/api/search`,
{ method: "POST", headers: { "Content-Type": "application/json", ...this.#authHeaders() }, body: JSON.stringify({ query: "sourcebot", matches: 1, isRegexEnabled: false }), redirect: "manual" },
opts.timeout ?? DEFAULT_TIMEOUT_MS,
);
if (res.status === 401 || res.status === 403) {
return { id: "*", state: "unknown", partial: true, detail: "reachable but login-gated (enable anonymous access with FORCE_ENABLE_ANONYMOUS_ACCESS=true for local use, or set SOURCEBOT_API_KEY)" };
}
return { id: "*", state: res.ok ? "ready" : "unknown", partial: true, detail: `HTTP ${res.status}` };
} catch {
return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` };
}
}
async #post(path: string, body: unknown, timeout: number): Promise<unknown> {
let res: Response;
try {
res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", ...this.#authHeaders() },
body: JSON.stringify(body),
}, timeout);
} catch (err) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id);
}
if (res.status === 401 || res.status === 403) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot is login-gated (HTTP ${res.status}); enable anonymous access (FORCE_ENABLE_ANONYMOUS_ACCESS=true) for local use, or set SOURCEBOT_API_KEY`, this.id);
}
if (!res.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id);
try {
return await res.json();
} catch (err) {
throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot returned non-JSON: ${(err as Error).message}`, this.id);
}
}
async #fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
// Every Sourcebot HTTP call routes through here. A loopback server keeps
// content on this machine (no egress, no receipt); a non-loopback server
// is an off-machine send and gets a fail-closed receipt BEFORE the fetch.
if (!this.local) {
const body = typeof init.body === "string" ? init.body : "";
writeReceipt({
sink: "sourcebot",
host: new URL(url).host,
payloadClass: "code-search-request",
bytes: Buffer.byteLength(body),
sha256: sha256Hex(body),
consent: "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + per-repo consented=true",
});
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
return await this.#fetch(url, { ...init, signal: controller.signal });
} catch (err) {
if ((err as Error)?.name === "AbortError") throw new CodeProviderError("PROVIDER_TIMEOUT", "Sourcebot request timed out", this.id);
throw err;
} finally {
clearTimeout(timer);
}
}
}
interface SourcebotMatchRange { start?: { lineNumber?: number } }
interface SourcebotChunk { content?: string; matchRanges?: SourcebotMatchRange[] }
interface SourcebotFile { fileName?: { text?: string }; repository?: string; chunks?: SourcebotChunk[] }
/**
* Map a `POST /api/search` response `{ files: [...] }` to hits: one hit per file,
* ref = file path, snippet = first matching chunk, kind = "file". Tolerant of a
* missing/garbage payload (returns []). Exported for deterministic testing.
*/
export function parseSourcebotSearch(payload: unknown, limit: number): CodeSearchHit[] {
const files = (payload as { files?: unknown })?.files;
if (!Array.isArray(files)) return [];
const hits: CodeSearchHit[] = [];
for (const f of files as SourcebotFile[]) {
const ref = f?.fileName?.text;
if (typeof ref !== "string") continue;
const chunk = f.chunks?.[0];
const line = chunk?.matchRanges?.[0]?.start?.lineNumber;
hits.push({
ref: typeof line === "number" ? `${ref}:${line}` : ref,
snippet: typeof chunk?.content === "string" ? chunk.content.trim() : undefined,
kind: "file",
});
if (hits.length >= limit) break;
}
return hits;
}

View File

@ -0,0 +1,55 @@
/**
* suggest should the session-start indexing offer be made for this repo?
*
* The offer fires at most once per machine: never when a provider is already
* selected, never after an explicit decline (`select none`), and never for
* small repos where grep is already fast. Detection is cheap and local
* (`git ls-files` count); a non-repo directory never triggers the offer.
*/
import { spawnSync } from "child_process";
import { resolve } from "path";
import { readSelection } from "./selection";
// ponytail: single tracked-file-count knob for "large"; add a LOC signal if it misfires
/** Tracked-file count at which indexing starts paying for itself. */
export const LARGE_REPO_FILE_THRESHOLD = 1000;
export type SuggestReason =
| "provider-selected"
| "declined"
| "not-a-repo"
| "small-repo"
| "large-repo";
export interface Suggestion {
offer: boolean;
reason: SuggestReason;
fileCount: number | null;
threshold: number;
}
/** Count of git-tracked files, or null when the path is not a git repo. */
export function trackedFileCount(repoPath: string): number | null {
const result = spawnSync("git", ["-C", resolve(repoPath), "ls-files"], {
encoding: "utf-8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0 || typeof result.stdout !== "string") return null;
const out = result.stdout.trim();
return out ? out.split("\n").length : 0;
}
export function shouldOfferIndexing(
repoPath: string,
opts: { env?: NodeJS.ProcessEnv; threshold?: number } = {},
): Suggestion {
const threshold = opts.threshold ?? LARGE_REPO_FILE_THRESHOLD;
const selection = readSelection(opts.env);
if (selection.provider) return { offer: false, reason: "provider-selected", fileCount: null, threshold };
if (selection.declined) return { offer: false, reason: "declined", fileCount: null, threshold };
const fileCount = trackedFileCount(repoPath);
if (fileCount === null) return { offer: false, reason: "not-a-repo", fileCount, threshold };
if (fileCount < threshold) return { offer: false, reason: "small-repo", fileCount, threshold };
return { offer: true, reason: "large-repo", fileCount, threshold };
}

View File

@ -915,6 +915,38 @@ Step 1.5 — fall through to Step 2 (where `no-cli` triggers Step 3 install and
---
## Step 1.7: Code-intelligence provider choice (Step 0 of indexing)
gbrain is one of THREE code-intelligence providers gstack can use; the offer
gate decides whether this repo is even worth indexing:
```bash
bun ~/.claude/skills/gstack/bin/gstack-code-intelligence suggest --json
```
- `"offer": false` with reason `small-repo` → grep is already fast here; say
so in one line and continue with this skill only if the user asked for
gbrain by name.
- `"offer": false` with reason `provider-selected` or `declined` → the
machine-wide question was already answered; apply it silently and continue.
- `"offer": true` → present the returned options ONCE via AskUserQuestion:
**GBrain** (recommended — semantic memory + code, sends repo content to
YOUR gbrain DB, per-repo consent), **Sourcebot** (self-hosted whole-repo
search, local when on localhost), **Graphify** (local tree-sitter graph,
nothing leaves the machine, user installs it), or **No indexing**. Record
the choice: `gstack-code-intelligence select <provider|none>``none`
persists the decline so NO skill ever asks again, on any repo
(re-enable: `gstack-code-intelligence select <provider>`). Local-compute
and remote-send providers are separate consents — never bundle them.
- Per-repo send consent (GBrain/Sourcebot) is recorded with
`gstack-code-intelligence consent <repo> yes|no` and is ALWAYS vetoed by a
`deny` tier in gstack-gbrain-repo-policy — the trust store is the single
authority for whether code leaves a repo.
If the user picked GBrain (or asked for this skill directly), continue below.
If they picked Sourcebot/Graphify, run `gstack-code-intelligence index <repo>`
and stop — the rest of this skill is gbrain-specific.
## Step 2: Pick a path (AskUserQuestion)
Only fire this if Step 1 shows no existing working config AND no shortcut

View File

@ -161,6 +161,38 @@ Step 1.5 — fall through to Step 2 (where `no-cli` triggers Step 3 install and
---
## Step 1.7: Code-intelligence provider choice (Step 0 of indexing)
gbrain is one of THREE code-intelligence providers gstack can use; the offer
gate decides whether this repo is even worth indexing:
```bash
bun ~/.claude/skills/gstack/bin/gstack-code-intelligence suggest --json
```
- `"offer": false` with reason `small-repo` → grep is already fast here; say
so in one line and continue with this skill only if the user asked for
gbrain by name.
- `"offer": false` with reason `provider-selected` or `declined` → the
machine-wide question was already answered; apply it silently and continue.
- `"offer": true` → present the returned options ONCE via AskUserQuestion:
**GBrain** (recommended — semantic memory + code, sends repo content to
YOUR gbrain DB, per-repo consent), **Sourcebot** (self-hosted whole-repo
search, local when on localhost), **Graphify** (local tree-sitter graph,
nothing leaves the machine, user installs it), or **No indexing**. Record
the choice: `gstack-code-intelligence select <provider|none>` — `none`
persists the decline so NO skill ever asks again, on any repo
(re-enable: `gstack-code-intelligence select <provider>`). Local-compute
and remote-send providers are separate consents — never bundle them.
- Per-repo send consent (GBrain/Sourcebot) is recorded with
`gstack-code-intelligence consent <repo> yes|no` and is ALWAYS vetoed by a
`deny` tier in gstack-gbrain-repo-policy — the trust store is the single
authority for whether code leaves a repo.
If the user picked GBrain (or asked for this skill directly), continue below.
If they picked Sourcebot/Graphify, run `gstack-code-intelligence index <repo>`
and stop — the rest of this skill is gbrain-specific.
## Step 2: Pick a path (AskUserQuestion)
Only fire this if Step 1 shows no existing working config AND no shortcut

View File

@ -0,0 +1,434 @@
/**
* Tests for lib/code-intelligence the OPTIONAL, repo-oriented provider contract
* with three REAL adapters (GBrain CLI, Graphify CLI, Sourcebot HTTP) and the
* selection store the `gstack-code-intelligence` CLI drives.
*
* The Graphify and Sourcebot expectations here are pinned to the REAL formats
* captured from live tools (graphify 0.9.23 NODE/EDGE query output; Sourcebot v5
* `/api/search` response + Bearer auth), not invented shapes.
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { execFileSync } from "child_process";
import {
REQUIRED_CAPABILITIES,
GbrainProvider,
GraphifyProvider,
SourcebotProvider,
parseGbrainSearch,
parseGraphifyQuery,
parseSourcebotSearch,
readSelection,
setProvider,
setConsent,
hasConsent,
setRoot,
getRoot,
resolveSelectedProvider,
RECOMMENDED_ORDER,
shouldOfferIndexing,
trackedFileCount,
} from "../lib/code-intelligence";
describe("capability matrix", () => {
test("every provider advertises the four required capabilities", () => {
for (const p of [new GbrainProvider(), new SourcebotProvider(), new GraphifyProvider()]) {
for (const cap of REQUIRED_CAPABILITIES) expect(p.has(cap)).toBe(true);
}
});
test("only GBrain advertises the document ops; local flags are right", () => {
const g = new GbrainProvider();
expect(g.local).toBe(false);
for (const cap of ["add", "delete", "export"] as const) expect(g.has(cap)).toBe(true);
const s = new SourcebotProvider({ baseUrl: "http://localhost:3000" });
expect(s.local).toBe(true); // loopback → content stays on machine
expect(s.has("add")).toBe(false);
expect(new SourcebotProvider({ baseUrl: "https://sb.example.com" }).local).toBe(false);
const gf = new GraphifyProvider();
expect(gf.local).toBe(true);
expect(gf.has("export")).toBe(true);
expect(gf.has("add")).toBe(false);
});
test("RECOMMENDED_ORDER puts GBrain first", () => {
expect([...RECOMMENDED_ORDER]).toEqual(["gbrain", "sourcebot", "graphify"]);
});
});
describe("parsers (pinned to real tool output)", () => {
test("parseGbrainSearch (text surface)", () => {
const hits = parseGbrainSearch("[0.91] slug/a -- one\nbanner\n[0.05] slug/b -- low", 0.1, 10);
expect(hits).toEqual([{ ref: "slug/a", score: 0.91, snippet: "one", kind: "document" }]);
});
test("parseGraphifyQuery reads file:line from real NODE/EDGE lines", () => {
// Verbatim shape from graphify 0.9.23 `query ... --graph`.
const real = [
"Traversal: BFS depth=2 | Start: ['query()'] | Context: call (heuristic) | 4 nodes found",
"",
"NODE query() [src=db.py loc=L4 community=login]",
"EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8",
].join("\n");
const hits = parseGraphifyQuery(real, 10);
expect(hits.map((h) => h.ref)).toEqual(["db.py:L4", "auth.py:L8"]); // NOT "graphify"
expect(hits.every((h) => h.kind === "graph-node")).toBe(true);
// The Traversal header must NOT become a bogus hit.
expect(hits.some((h) => h.snippet?.startsWith("Traversal:"))).toBe(false);
});
test("parseSourcebotSearch maps files to file:line hits (real v5 shape)", () => {
const real = {
files: [
{
fileName: { text: "src/checksum.ts", matchRanges: [] },
repository: "github.com/example/sb-sample",
chunks: [{ content: "export function computeChecksum(data: string): number {", matchRanges: [{ start: { byteOffset: 58, column: 17, lineNumber: 2 } }] }],
},
],
};
expect(parseSourcebotSearch(real, 10)).toEqual([
{ ref: "src/checksum.ts:2", snippet: "export function computeChecksum(data: string): number {", kind: "file" },
]);
expect(parseSourcebotSearch("nope", 10)).toEqual([]);
});
});
describe("selection store + provider-OFF", () => {
let home: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-home-"));
env = { ...process.env, GSTACK_HOME: home };
});
afterEach(() => fs.rmSync(home, { recursive: true, force: true }));
test("no selection = provider-OFF (null)", () => {
expect(readSelection(env).provider).toBeNull();
expect(resolveSelectedProvider({ env })).toBeNull();
});
test("select persists and resolves the provider", () => {
setProvider("graphify", env);
expect(readSelection(env).provider).toBe("graphify");
expect(resolveSelectedProvider({ env })?.id).toBe("graphify");
});
test("consent is per-repo", () => {
const repo = path.join(home, "repoA");
expect(hasConsent(repo, env)).toBe(false);
setConsent(repo, true, env);
expect(hasConsent(repo, env)).toBe(true);
expect(hasConsent(path.join(home, "repoB"), env)).toBe(false);
});
test("indexed root persists per provider (so search reads the same graph)", () => {
expect(getRoot("graphify", env)).toBeUndefined();
setRoot("graphify", "/tmp/some/repo", env);
expect(getRoot("graphify", env)).toBe(path.resolve("/tmp/some/repo"));
});
test("select none records the decline; selecting a provider clears it", () => {
setProvider(null, env);
expect(readSelection(env).declined).toBe(true);
setProvider("graphify", env);
expect(readSelection(env).declined).toBe(false);
});
});
describe("session-start indexing offer (suggest)", () => {
let home: string;
let repo: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-home-"));
repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-repo-"));
env = { ...process.env, GSTACK_HOME: home };
Bun.spawnSync(["git", "init", "-q", repo]);
for (const name of ["a.ts", "b.ts", "c.ts"]) fs.writeFileSync(path.join(repo, name), "x\n");
Bun.spawnSync(["git", "-C", repo, "add", "-A"]);
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
});
test("offers exactly once: large repo with no prior decision", () => {
const s = shouldOfferIndexing(repo, { env, threshold: 3 });
expect(s).toEqual({ offer: true, reason: "large-repo", fileCount: 3, threshold: 3 });
});
test("small repo → no offer", () => {
expect(shouldOfferIndexing(repo, { env, threshold: 4 }).reason).toBe("small-repo");
});
test("not a git repo → no offer", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-plain-"));
try {
expect(shouldOfferIndexing(dir, { env, threshold: 0 }).reason).toBe("not-a-repo");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("provider already selected → no offer", () => {
setProvider("graphify", env);
expect(shouldOfferIndexing(repo, { env, threshold: 3 }).reason).toBe("provider-selected");
});
test("explicit decline → never asked again", () => {
setProvider(null, env);
expect(shouldOfferIndexing(repo, { env, threshold: 3 }).reason).toBe("declined");
});
test("trackedFileCount counts git-tracked files only", () => {
fs.writeFileSync(path.join(repo, "untracked.ts"), "x\n");
expect(trackedFileCount(repo)).toBe(3);
});
});
describe("egress consent gate", () => {
test("GBrain (non-local) registerSource without consent → PROVIDER_NOT_CONSENTED", async () => {
await expect(new GbrainProvider().registerSource({ id: "code", path: "/repo" })).rejects.toMatchObject({
code: "PROVIDER_NOT_CONSENTED",
});
});
test("Graphify (local) is exempt from the egress gate", async () => {
await expect(
new GraphifyProvider({ env: { PATH: "/nonexistent" } }).registerSource({ id: "r", path: os.tmpdir() }),
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
});
});
describe("Graphify adapter (fake graphify shim, real NODE/EDGE format)", () => {
let binDir: string;
let repo: string;
function env(): NodeJS.ProcessEnv {
return { PATH: `${binDir}:${process.env.PATH}` };
}
beforeEach(() => {
binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gf-bin-"));
repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gf-repo-"));
// Shim emulates real graphify 0.9.23: `update <path>` writes graph.json (no LLM);
// `query <q> --graph <path>` prints NODE/EDGE lines.
fs.writeFileSync(
path.join(binDir, "graphify"),
`#!/usr/bin/env bash
case "$1" in
--version) echo "graphify 0.9.23"; exit 0;;
update) mkdir -p "$2/graphify-out"; echo '{"nodes":[1,2,3,4,5],"edges":[]}' > "$2/graphify-out/graph.json"; echo "Rebuilt: 5 nodes, 8 edges"; exit 0;;
query)
echo "Traversal: BFS depth=2 | Start: ['query()'] | 4 nodes found"
echo ""
echo "NODE query() [src=db.py loc=L4 community=login]"
echo "EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8"
exit 0;;
esac
exit 1
`,
{ mode: 0o755 },
);
});
afterEach(() => {
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
});
test("index builds a graph (via `graphify update`) and status counts nodes", async () => {
const gf = new GraphifyProvider({ root: repo, env: env() });
const reg = await gf.registerSource({ id: repo, path: repo });
expect(reg.state).toBe("ready");
expect(reg.itemCount).toBe(5);
expect(fs.existsSync(path.join(repo, "graphify-out", "graph.json"))).toBe(true);
});
test("search reads file:line refs from real query output", async () => {
const gf = new GraphifyProvider({ root: repo, env: env() });
await gf.registerSource({ id: repo, path: repo });
const hits = await gf.search("what calls db", { source: repo });
expect(hits.map((h) => h.ref)).toEqual(["db.py:L4", "auth.py:L8"]);
});
test("missing graphify CLI degrades to PROVIDER_UNAVAILABLE", async () => {
await expect(
new GraphifyProvider({ root: repo, env: { PATH: os.tmpdir() } }).search("q"),
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
});
});
describe("Sourcebot adapter (injected fetch, real v5 auth + shape)", () => {
test("registerSource writes a local git connection to config.json", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-sb-"));
const configPath = path.join(dir, "config.json");
await new SourcebotProvider({ baseUrl: "http://localhost:3000", configPath }).registerSource({ id: "myrepo", path: "/abs/repo" });
const written = JSON.parse(fs.readFileSync(configPath, "utf-8"));
expect(written.connections.myrepo).toEqual({ type: "git", url: "file:///abs/repo" });
fs.rmSync(dir, { recursive: true, force: true });
});
test("search sends Bearer auth and maps the real v5 response to hits", async () => {
const seen: Array<{ url: string; auth: string | null }> = [];
const fetchStub = (async (url: string, init: RequestInit) => {
seen.push({ url: String(url), auth: (init.headers as Record<string, string>)?.Authorization ?? null });
return new Response(
JSON.stringify({ files: [{ fileName: { text: "a.ts" }, chunks: [{ content: "x", matchRanges: [{ start: { lineNumber: 3 } }] }] }] }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
const sb = new SourcebotProvider({ baseUrl: "http://localhost:3000", apiKey: "sbk_test", fetch: fetchStub });
const hits = await sb.search("foo");
expect(seen[0].url).toBe("http://localhost:3000/api/search");
expect(seen[0].auth).toBe("Bearer sbk_test");
expect(hits).toEqual([{ ref: "a.ts:3", snippet: "x", kind: "file" }]);
});
test("401 (no API key) degrades to PROVIDER_UNAVAILABLE, not PROVIDER_ERROR", async () => {
const fetchStub = (async () =>
new Response(JSON.stringify({ errorCode: "NOT_AUTHENTICATED" }), { status: 401 })) as unknown as typeof fetch;
await expect(
new SourcebotProvider({ baseUrl: "http://localhost:3000", fetch: fetchStub }).search("q"),
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
});
test("unreachable server degrades to PROVIDER_UNAVAILABLE", async () => {
const fetchStub = (async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch;
await expect(
new SourcebotProvider({ baseUrl: "http://localhost:3999", fetch: fetchStub }).search("q"),
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
});
test("registerSource without SOURCEBOT_CONFIG → PROVIDER_UNAVAILABLE", async () => {
await expect(
new SourcebotProvider({ baseUrl: "http://localhost:3000", env: {} }).registerSource({ id: "r", path: "/x" }),
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
});
});
describe("GBrain adapter (fake gbrain shim)", () => {
let binDir: string;
let homeDir: string;
function env(): NodeJS.ProcessEnv {
return { PATH: `${binDir}:${process.env.PATH}`, HOME: homeDir };
}
function writeShim(body: string): void {
fs.writeFileSync(path.join(binDir, "gbrain"), body, { mode: 0o755 });
}
beforeEach(() => {
binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-bin-"));
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-home-"));
});
afterEach(() => {
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
});
test("search parses hits (and sends no --source: gbrain search is global)", async () => {
writeShim(`#!/usr/bin/env bash
if [ "$1" = "search" ]; then
if printf '%s ' "$@" | grep -q -- "--source"; then echo "[0.0] ERR -- adapter sent phantom --source"; exit 0; fi
echo "[0.88] src/x.ts -- match"; exit 0
fi
exit 1
`);
const hits = await new GbrainProvider().search("where", { env: env() });
expect(hits).toEqual([{ ref: "src/x.ts", score: 0.88, snippet: "match", kind: "document" }]);
});
test("refresh runs the code-indexing pass (`sync --strategy code --full`)", async () => {
// Real gbrain only indexes code when `sync --strategy code` runs; without it
// code-def stays not_built. Pin that the adapter issues that pass.
const marker = path.join(homeDir, "sync-calls.log");
writeShim(`#!/usr/bin/env bash
if [ "$1" = "sync" ]; then printf '%s\\n' "$*" >> "${marker}"; exit 0; fi
if [ "$1" = "sources" ]; then echo '{"sources":[{"id":"code","local_path":"/r","page_count":1}]}'; exit 0; fi
exit 1
`);
await new GbrainProvider().refresh({ id: "code" }, { env: env(), consented: true });
const log = fs.readFileSync(marker, "utf-8");
expect(log).toContain("--strategy code");
expect(log).toContain("--full");
});
test("engine-down (pglite WASM) degrades to PROVIDER_UNAVAILABLE, not PROVIDER_ERROR", async () => {
// Reproduces garrytan/gbrain#223: engine fails to init; must degrade cleanly.
writeShim(`#!/usr/bin/env bash
echo "PGLite failed to initialize its WASM runtime." >&2
echo " Original error: Aborted()." >&2
exit 1
`);
await expect(new GbrainProvider().search("q", { env: env() })).rejects.toMatchObject({
code: "PROVIDER_UNAVAILABLE",
});
});
test("missing CLI degrades to PROVIDER_UNAVAILABLE", async () => {
await expect(
new GbrainProvider().search("q", { env: { PATH: binDir, HOME: homeDir } }),
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
});
});
// ── R1: the repo-policy deny tier vetoes recorded consent ───────────────────
// Two consent stores must never disagree about whether code may leave a repo:
// gstack-gbrain-repo-policy (per-remote trust tiers) is the single authority.
describe("consent unification — deny tier wins (R1)", () => {
function makeRepo(dir: string, url: string): string {
const repo = path.join(dir, "repo");
fs.mkdirSync(repo, { recursive: true });
const git = (...a: string[]) => execFileSync("git", a, { cwd: repo });
git("init", "-q", ".");
git("remote", "add", "origin", url);
return repo;
}
const POLICY_BIN = path.join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
const URL = "https://github.com/acme/veto-widget.git";
test("recorded consent survives when no policy store exists", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
try {
const env = { ...process.env, GSTACK_HOME: home };
const repo = makeRepo(home, URL);
setConsent(repo, true, env);
expect(hasConsent(repo, env)).toBe(true);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
test("deny tier vetoes recorded consent", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
try {
const env = { ...process.env, GSTACK_HOME: home };
const repo = makeRepo(home, URL);
setConsent(repo, true, env);
execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8" });
expect(hasConsent(repo, env)).toBe(false);
// Flipping the tier back restores the recorded consent — the veto is
// live policy, not a destructive rewrite of the consent store.
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8" });
expect(hasConsent(repo, env)).toBe(true);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
test("unreadable policy store fails closed (consent vetoed)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
try {
const env = { ...process.env, GSTACK_HOME: home };
const repo = makeRepo(home, URL);
setConsent(repo, true, env);
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8" });
fs.chmodSync(path.join(home, "gbrain-repo-policy.json"), 0o000);
try {
expect(hasConsent(repo, env)).toBe(false);
} finally {
fs.chmodSync(path.join(home, "gbrain-repo-policy.json"), 0o600);
}
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
});

View File

@ -67,6 +67,13 @@ const MODULE_SINKS = [
'bin/gstack-gbrain-sync.ts',
'bin/gstack-memory-ingest.ts',
'browse/src/server.ts',
// Code-intelligence adapters (fork port wave 2): the gbrain adapter shells
// repo content to the user's gbrain DB and the Sourcebot adapter POSTs
// queries to a self-hosted HTTP endpoint — both sensitive-class
// (repo-content) sinks, fail-closed. Registered so a refactor that drops
// their writeReceipt calls fails CI, not just the tree-sweep scanner.
'lib/code-intelligence/gbrain-adapter.ts',
'lib/code-intelligence/sourcebot-adapter.ts',
// Unconditional: context-bill ships in the same tree as this tripwire. A
// missing file must fail loudly (a rename/move that drops its receipt wiring
// is exactly what this pins), not silently soften the assertion.