feat: code-intelligence contract with real GBrain/Graphify/Sourcebot adapters

contract.ts: repo-oriented interface (four required ops, three optional),
typed CodeProviderError, egress + capability consent guards.

Three real, runtime-drivable adapters:
- GbrainProvider: gbrain CLI (reuses lib/gbrain-exec + lib/gbrain-sources),
  all seven capabilities.
- GraphifyProvider: graphify CLI — `graphify <dir>` builds the local graph,
  `graphify query` searches it, export reads graphify-out/graph.json. Fully
  local; never auto-installed.
- SourcebotProvider: self-hosted server over HTTP — register writes a local
  git connection to config.json, search is POST /api/search, status is a
  liveness probe. Loopback base URL = local (no egress); remote = consent.

selection.ts persists the chosen provider + per-repo indexing consent under
$GSTACK_HOME. picker.ts recommends GBrain first, resolves the selected
provider or null (provider-OFF), and probes live availability. Every adapter
degrades to PROVIDER_UNAVAILABLE when its tool/server is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-21 17:16:54 -07:00
co-authored by Claude Opus 4.8
parent b4421a4e69
commit 3eb4a1b23f
5 changed files with 508 additions and 27 deletions
+68
View File
@@ -0,0 +1,68 @@
/**
* 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 type { CodeProviderId } from "./contract";
export interface Selection {
provider: CodeProviderId | null;
/** Absolute repo path → consented. */
consents: Record<string, boolean>;
}
const EMPTY: Selection = { provider: null, consents: {} };
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 : {},
};
} 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 {
const next = { ...readSelection(env), provider };
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;
}
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
return readSelection(env).consents[resolve(repoPath)] === true;
}