diff --git a/.gitignore b/.gitignore index 731142ed..3cf2ddb7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ tmp/ *.d.ts.map .env .env.* +# Personal per-directory Hivemind override (routing / opt-out). The committed +# `.hivemind` is shared; `.hivemind.local` is yours alone — never commit it. +.hivemind.local coverage/ jscpd-report/ bench/ diff --git a/.hivemind.example b/.hivemind.example new file mode 100644 index 00000000..3d13e144 --- /dev/null +++ b/.hivemind.example @@ -0,0 +1,5 @@ +{ + "orgId": "your-org-id-or-name", + "workspaceId": "your-workspace", + "collect": true +} diff --git a/README.md b/README.md index e2c37265..0708986d 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ Disable capture entirely: HIVEMIND_CAPTURE=false claude ``` +Disable capture for a specific directory tree (persistent, travels with the repo) by dropping a `.hivemind` file with `{ "collect": false }`. See [Per-directory config](#per-directory-config-hivemind). + Enable debug logging: ```bash @@ -323,6 +325,85 @@ This plugin captures session activity and stores it in your Deeplake workspace: | `HIVEMIND_RECALL_TIMEOUT_MS` | `1000` | Proactive recall: hard cap on the synchronous search path; on timeout it skips rather than delay the turn. | | `HIVEMIND_DEBUG` | _(none)_ | Set to `1` for verbose hook debug logs | +## Per-directory config (`.hivemind`) + +The variables above set **one global identity** for the whole machine. A `.hivemind` file lets a specific directory tree override that: either **route** its traces to a different org/workspace, or **opt out** of capture entirely. + +Drop a `.hivemind` JSON file at the root of the tree you want to configure: + +```json +{ + "orgId": "acme-corp", + "workspaceId": "client-work", + "collect": true +} +``` + +| Field | Effect | +|---------------|-------------------------------------------------------------------------------| +| `orgId` | Route captured traces from this tree to this org. | +| `workspaceId` | Route to this workspace. | +| `collect` | `false` → **never** capture traces from this tree. | + +Any field may be omitted; omitted fields fall back to your global identity. + +**Two common recipes:** + +```jsonc +// route this repo's traces to a client org/workspace +{ "orgId": "acme-corp", "workspaceId": "client-work" } + +// never collect traces from this folder (e.g. a personal or sensitive repo) +{ "collect": false } +``` + +### Committed vs local + +Two filenames are recognized, mirroring the `.env` / `.env.local` convention every dev already knows: + +| File | Commit it? | For | +|------|-----------|-----| +| `.hivemind` | **Yes** (like `.editorconfig`) | The repo declaring where *its* traces belong (or that it's off-limits). Teammates who clone inherit it. | +| `.hivemind.local` | **No** (gitignore it) | *Your personal* override or opt-out, not imposed on teammates. Wins over `.hivemind` in the same directory. | + +There's nothing to hide: a `.hivemind` can't carry a token (see below), so committing one is safe. It just declares intent. Use `.hivemind.local` only when a choice is yours alone (add it to your repo's `.gitignore`, like `.env.local`). + +A copy-ready template lives at [`.hivemind.example`](.hivemind.example). Run `cp .hivemind.example .hivemind` and edit. (The `.example` file is inert; Hivemind only reads `.hivemind` and `.hivemind.local`.) + +### How it resolves + +When a session starts, Hivemind walks **up** from the working directory (`cwd`, its parent, its grandparent, and so on) and uses the **first** file it finds (a `.hivemind.local` beats a `.hivemind` in the same directory). Nearest wins; ancestors above it are ignored. This is the `.git`/`.gitconfig` model, **not** `.gitignore`-style merging. There is no inheritance: a leaf file that wants both its parent's org and its own workspace must state both. + +``` +~/work/.hivemind { "orgId": "acme-corp" } +~/work/client/.hivemind { "workspaceId": "sensitive", "collect": false } + +session in ~/work/client/svc/ → uses client/.hivemind ONLY + (collect:false wins; the org above is NOT inherited) +session in ~/work/other/ → no .hivemind found → global identity +``` + +**Precedence** is the conventional `env > file > login`: an explicitly-set `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` overrides a `.hivemind` routing value (that field is left untouched), which in turn overrides your logged-in default. `collect: false` is a fail-safe opt-out and is always honored. + +### Safety: routing is disclosed, not hidden + +Because a `.hivemind` travels with a repo, cloning someone's repo could in principle point *your* traces at a different org. Two things keep that safe, with no approval step or ceremony: + +- **`.hivemind` never contains a token.** Auth stays in `~/.deeplake/credentials.json`, so a routing override can only ever target orgs your existing login **already** authorizes; the API rejects anything else. It can't leak your traces to a stranger's org. At worst it misfiles them into another of *your own* orgs. +- **Every session tells you where its traces go.** The session-start banner prints the **effective** org/workspace after any `.hivemind` overlay, e.g. `org: acme-corp (workspace: client-work) · routed by ./.hivemind`, or `capture is disabled for this directory` when you've opted out. So a redirect is never silent; if it's not what you want, delete the file or add `.hivemind.local`. + +### Interaction with `org switch` / `workspace switch` + +`hivemind org switch` and `hivemind workspace switch` change your **global default** (they write `~/.deeplake/credentials.json`). A `.hivemind` is a **pin on top of that default**: + +| Location | Where traces go | +|------------------------------|------------------------------------------------------------| +| Dir with a routing `.hivemind` | The pinned org/workspace, **unaffected** by `org switch`. | +| Dir with **no** `.hivemind` | Follows your current global default (i.e. `org switch`). | +| Dir with `collect: false` | Nothing captured, regardless of the global default. | + +So `org switch` moves everything that *isn't* explicitly pinned; a pin stays put by design (that's the point of routing a client repo to a fixed org). The session-start banner always shows the **effective** identity for your current directory, so a pinned tree never silently surprises you. + ## Semantic search (optional) Hivemind ships with a local embedding daemon (nomic-embed-text-v1.5) for hybrid semantic + lexical search over `~/.deeplake/memory/`. **Off by default** because the dependency footprint is ~600 MB. Enable with `hivemind embeddings install` (or `hivemind install --with-embeddings`). Without it, search degrades silently to BM25/lexical-only. diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index cbad4b44..82af0f47 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -93,6 +93,62 @@ function loadCreds(): Creds | null { } } +// Per-directory `.hivemind` (route org/workspace, or opt out of capture). +// Self-contained mirror of src/dir-config.ts — pi extensions ship as raw .ts +// with no shared-module imports, so keep in lockstep with that file. Walk up +// from cwd for the nearest `.hivemind.local` / `.hivemind` (nearest wins, +// `.local` beats committed), and overlay org/workspace onto creds. Precedence +// is env > file > login: HIVEMIND_ORG_ID / HIVEMIND_WORKSPACE_ID lock a field. +interface PiDirConfig { orgId?: string; orgName?: string; workspaceId?: string; collect?: boolean; } + +function findHivemindDir(startDir: string): PiDirConfig | null { + let dir = startDir || process.cwd(); + for (;;) { + for (const name of [".hivemind.local", ".hivemind"]) { + try { + const raw = JSON.parse(readFileSync(join(dir, name), "utf-8")); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const out: PiDirConfig = {}; + if (typeof raw.orgId === "string") out.orgId = raw.orgId; + if (typeof raw.orgName === "string") out.orgName = raw.orgName; + if (typeof raw.workspaceId === "string") out.workspaceId = raw.workspaceId; + if (typeof raw.collect === "boolean") out.collect = raw.collect; + return out; + } + } catch { /* absent / unparseable — keep walking up */ } + } + const parent = dirname(dir); + if (parent === dir) return null; // filesystem root + dir = parent; + } +} + +/** Overlay env + the nearest `.hivemind` onto `creds` for `cwd`, in the + * conventional env > file > login order. Returns the effective creds, whether + * capture is enabled here, and whether a `.hivemind` routed the identity. */ +function applyDirConfig(creds: Creds, cwd: string): { creds: Creds; collect: boolean; routed: boolean } { + // Env wins over both the file and login (mirrors src/config.ts's + // `process.env.HIVEMIND_ORG_ID ?? creds.orgId`). Fold it into the base first + // so it holds whether or not a `.hivemind` is present, and so the lock below + // reflects an actually-applied value rather than a no-op. + const envOrgId = process.env.HIVEMIND_ORG_ID; + const envWs = process.env.HIVEMIND_WORKSPACE_ID; + const baseOrgId = envOrgId || creds.orgId; + const baseOrgName = envOrgId ? (creds.orgName ?? envOrgId) : creds.orgName; + const baseWs = envWs || creds.workspaceId; + const withEnv: Creds = { ...creds, orgId: baseOrgId, orgName: baseOrgName, workspaceId: baseWs }; + + const dir = findHivemindDir(cwd || process.cwd()); + if (!dir) return { creds: withEnv, collect: true, routed: false }; + if (dir.collect === false) return { creds: withEnv, collect: false, routed: false }; + // The file may fill only fields NOT pinned by an env var. + const orgId = envOrgId ? baseOrgId : (dir.orgId ?? baseOrgId); + const orgName = envOrgId ? baseOrgName : (dir.orgName ?? dir.orgId ?? baseOrgName); + const workspaceId = envWs ? baseWs : (dir.workspaceId ?? baseWs); + const routed = orgId !== baseOrgId || workspaceId !== baseWs; // .hivemind changed it + return { creds: { ...withEnv, orgId, orgName, workspaceId }, collect: true, routed }; +} + // Inline copies of decodeJwtPayload + healDriftedOrgToken (the shared helpers // live in src/commands/auth.ts, but pi extensions ship as raw .ts with no // shared-module imports — kept in lockstep with that file). @@ -1214,7 +1270,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { // ctx.cwd are the canonical sources for session id + cwd — the events // themselves don't carry them. - pi.on("session_start", async (_event: any, _ctx: any) => { + pi.on("session_start", async (_event: any, ctx: any) => { logHm(`session_start: fired (capture=${captureEnabled}, embed=${process.env.HIVEMIND_EMBEDDINGS !== "false"}, table=${SESSIONS_TABLE})`); let creds = loadCreds(); if (!creds) { @@ -1224,6 +1280,18 @@ export default function hivemindExtension(pi: ExtensionAPI): void { creds = await healDriftedOrgTokenInline(creds); } + // Per-directory `.hivemind`: opt out of capture (collect:false), or route + // to a configured org/workspace, based on the session's cwd. + const sessionCwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd(); + let dirCollect = true; + let dirRouted = false; + if (creds) { + const dr = applyDirConfig(creds, sessionCwd); + dirCollect = dr.collect; + dirRouted = dr.routed; + if (dr.collect) creds = dr.creds; // route only when we're capturing here + } + // Centralized autoupdate: shells out to `hivemind update` (npm-based, // refreshes every detected agent in one shot). Best-effort, fully // self-contained because the pi extension ships as raw .ts (no shared- @@ -1249,7 +1317,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { } catch { /* network down / which missing — silent */ } } - if (creds && captureEnabled) { + if (creds && captureEnabled && dirCollect) { // Other agents' session-start hooks create the memory + sessions tables // via DeeplakeApi.ensureTable / ensureSessionsTable. The pi extension is // standalone (no shared lib import to keep it raw-.ts), so we issue the @@ -1357,8 +1425,11 @@ export default function hivemindExtension(pi: ExtensionAPI): void { const localMinedNote = localMined > 0 ? `\n${localMined} local skill${localMined === 1 ? "" : "s"} from past 'hivemind skillify mine-local' run(s) live in ~/.claude/skills/. Run 'hivemind login' to start sharing new mining results with your team.` : ""; + const identityLine = !dirCollect + ? `Deeplake capture is disabled for this directory (.hivemind); memory search still uses org: ${creds?.orgName ?? creds?.orgId}.` + : `Logged in to Deeplake as org: ${creds?.orgName ?? creds?.orgId} (workspace: ${creds?.workspaceId})${dirRouted ? " · routed by .hivemind" : ""}.`; const additional = creds - ? `${CONTEXT_PREAMBLE}\nLogged in to Deeplake as org: ${creds.orgName ?? creds.orgId} (workspace: ${creds.workspaceId}).` + ? `${CONTEXT_PREAMBLE}\n${identityLine}` : `${CONTEXT_PREAMBLE}\nNot logged in to Deeplake. Run \`hivemind login\` to authenticate.${localMinedNote}`; return { additionalContext: additional }; }); @@ -1367,12 +1438,15 @@ export default function hivemindExtension(pi: ExtensionAPI): void { logHm(`input: fired source=${event?.source ?? "?"}`); if (!captureEnabled) { logHm(`input: capture disabled, skipping`); return; } if (event.source === "extension") { logHm(`input: extension-injected, skipping`); return; } - const creds = loadCreds(); + let creds = loadCreds(); if (!creds) { logHm(`input: no creds, skipping`); return; } const text = typeof event.text === "string" ? event.text : ""; if (!text) { logHm(`input: empty text, skipping`); return; } const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`; const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd(); + const dirRes = applyDirConfig(creds, cwd); + if (!dirRes.collect) { logHm(`input: capture disabled for cwd=${cwd} via .hivemind`); return; } + creds = dirRes.creds; try { await writeSessionRow(creds, sessionId, "pi", "input", cwd, { id: crypto.randomUUID(), @@ -1392,10 +1466,13 @@ export default function hivemindExtension(pi: ExtensionAPI): void { pi.on("tool_result", async (event: any, ctx: any) => { logHm(`tool_result: fired tool=${event?.toolName ?? "?"} isError=${event?.isError === true}`); if (!captureEnabled) { logHm(`tool_result: capture disabled, skipping`); return; } - const creds = loadCreds(); + let creds = loadCreds(); if (!creds) { logHm(`tool_result: no creds, skipping`); return; } const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`; const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd(); + const dirRes = applyDirConfig(creds, cwd); + if (!dirRes.collect) { logHm(`tool_result: capture disabled for cwd=${cwd} via .hivemind`); return; } + creds = dirRes.creds; // event.content is (TextContent | ImageContent)[]; extract text blocks. const contentBlocks: any[] = Array.isArray(event.content) ? event.content : []; const responseText = contentBlocks @@ -1426,7 +1503,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { pi.on("message_end", async (event: any, ctx: any) => { logHm(`message_end: fired role=${event?.message?.role ?? "?"}`); if (!captureEnabled) { logHm(`message_end: capture disabled, skipping`); return; } - const creds = loadCreds(); + let creds = loadCreds(); if (!creds) { logHm(`message_end: no creds, skipping`); return; } const message = event.message ?? null; // AgentMessage is UserMessage | AssistantMessage | ToolResultMessage. @@ -1444,6 +1521,9 @@ export default function hivemindExtension(pi: ExtensionAPI): void { if (!text) { logHm(`message_end: assistant message had no text blocks, skipping`); return; } const sessionId = ctx?.sessionManager?.getSessionId?.() ?? `pi-${Date.now()}`; const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd(); + const dirRes = applyDirConfig(creds, cwd); + if (!dirRes.collect) { logHm(`message_end: capture disabled for cwd=${cwd} via .hivemind`); return; } + creds = dirRes.creds; try { await writeSessionRow(creds, sessionId, "pi", "message_end", cwd, { id: crypto.randomUUID(), @@ -1461,11 +1541,14 @@ export default function hivemindExtension(pi: ExtensionAPI): void { pi.on("session_shutdown", async (_event: any, ctx: any) => { logHm(`session_shutdown: fired`); if (process.env.HIVEMIND_CAPTURE === "false") return; - const creds = loadCreds(); + let creds = loadCreds(); if (!creds) { logHm(`session_shutdown: no creds, skipping final summary`); return; } const sessionId = ctx?.sessionManager?.getSessionId?.() ?? null; if (!sessionId) { logHm(`session_shutdown: no sessionId, skipping final summary`); return; } const cwd = ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd(); + const dirRes = applyDirConfig(creds, cwd); + if (!dirRes.collect) { logHm(`session_shutdown: capture disabled for cwd=${cwd} via .hivemind`); return; } + creds = dirRes.creds; // Always spawn for "final" — but the lock check inside spawnWikiWorker // skips if a periodic worker is mid-flight. Non-fatal either way. spawnWikiWorker(creds, sessionId, cwd, "final"); diff --git a/src/commands/graph.ts b/src/commands/graph.ts index ec9e9dda..897c793f 100644 --- a/src/commands/graph.ts +++ b/src/commands/graph.ts @@ -537,7 +537,9 @@ export async function runBuildCommand(args: string[]): Promise { // logs and returns; the local snapshot is the source of truth. Skips // silently when not authenticated (loadConfig returns null). // worktreeId already computed above for the writeSnapshot call. - const pushOutcome = await pushSnapshot(snapshot, worktreeId); + // Pass the resolved build cwd so `.hivemind` resolves against the target + // tree (honors `--cwd`), not the process's invocation directory. + const pushOutcome = await pushSnapshot(snapshot, worktreeId, { cwd }); switch (pushOutcome.kind) { case "inserted": console.log(`Cloud: pushed to codebase table (commit ${pushOutcome.commitSha.slice(0, 7)})`); @@ -559,6 +561,9 @@ export async function runBuildCommand(args: string[]): Promise { case "skipped-disabled": console.log(`Cloud: skipped (HIVEMIND_GRAPH_PUSH=0)`); break; + case "skipped-collect-disabled": + console.log(`Cloud: skipped (.hivemind collect:false for this directory)`); + break; case "drift": console.warn(`Cloud: DRIFT — commit ${pushOutcome.commitSha.slice(0, 7)} is in cloud with`); console.warn(` sha256=${pushOutcome.cloudSha256.slice(0, 12)}... but local rebuild produced`); diff --git a/src/dir-config.ts b/src/dir-config.ts new file mode 100644 index 00000000..784a6831 --- /dev/null +++ b/src/dir-config.ts @@ -0,0 +1,140 @@ +/** + * Per-directory Hivemind config — an in-tree `.hivemind` file that overlays the + * global identity (`~/.deeplake/credentials.json`) for sessions launched under + * that directory. Two things it can do: + * + * 1. ROUTE — send this directory's captured traces to a specific + * org / workspace: + * { "orgId": "acme", "workspaceId": "clientA" } + * 2. OPT OUT — never collect traces from this directory: + * { "collect": false } + * + * Two filenames are recognized at each level: + * - `.hivemind` — meant to be COMMITTED (shared team routing, like + * `.editorconfig`). + * - `.hivemind.local` — meant to be GITIGNORED (a personal override / + * opt-out, like `.env.local`). Wins over `.hivemind` + * in the same directory. + * + * Discovery is nearest-wins: we walk UP from the session `cwd` and take the + * first file found (like `.git`), so routing is unambiguous — NOT a + * `.gitignore`-style merge across ancestors. + * + * The file NEVER carries a token — auth stays in `~/.deeplake/credentials.json`. + * A routing override therefore only ever takes effect against orgs the user's + * existing token already authorizes; the API rejects anything else. Where the + * traces actually land is disclosed to the user in the session-start banner, + * so a routing override is never silent. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import type { Config } from "./config.js"; + +/** Committed (shared) and local (personal, gitignored) filenames, local first. */ +export const DIR_CONFIG_FILENAMES = [".hivemind.local", ".hivemind"] as const; + +export interface DirConfigFile { + orgId?: string; + orgName?: string; + workspaceId?: string; + /** false → never capture traces from this directory. Default true. */ + collect?: boolean; +} + +export interface FoundDirConfig { + /** Absolute path to the file that applied. */ + path: string; + raw: DirConfigFile; +} + +/** + * Walk up from `startDir` (to the filesystem root, or `stopAt` inclusive) + * returning the nearest readable, parseable config. At each level a + * `.hivemind.local` beats a `.hivemind`; nearer directories beat farther ones. + */ +export function findDirConfig(startDir: string, stopAt?: string): FoundDirConfig | null { + let dir = resolve(startDir); + const boundary = stopAt ? resolve(stopAt) : null; + for (;;) { + for (const name of DIR_CONFIG_FILENAMES) { + const candidate = join(dir, name); + try { + const raw = parseDirConfig(readFileSync(candidate, "utf-8")); + if (raw) return { path: candidate, raw }; + } catch { + // absent / unreadable — try the next name / level + } + } + if (boundary && dir === boundary) break; + const parent = dirname(dir); + if (parent === dir) break; // reached filesystem root + dir = parent; + } + return null; +} + +/** Parse a `.hivemind` JSON body, whitelisting known fields. Null on garbage. */ +export function parseDirConfig(contents: string): DirConfigFile | null { + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const o = parsed as Record; + const out: DirConfigFile = {}; + if (typeof o.orgId === "string") out.orgId = o.orgId; + if (typeof o.orgName === "string") out.orgName = o.orgName; + if (typeof o.workspaceId === "string") out.workspaceId = o.workspaceId; + if (typeof o.collect === "boolean") out.collect = o.collect; + return out; +} + +export interface ResolvedDirConfig { + /** Config to capture with — org/workspace-overlaid when a `.hivemind` routes. */ + config: Config; + /** false → caller must skip capture entirely for this cwd. */ + collect: boolean; + /** The file that applied, if any (for the session-start banner / diagnostics). */ + found: FoundDirConfig | null; +} + +/** + * Overlay the nearest `.hivemind` onto `base` for a session in `cwd`. + * `collect: false` suppresses capture; org/workspace fields (when present) + * redirect it. Omitted fields fall back to the global identity in `base`. + * + * Precedence follows the conventional `env > config-file > stored-creds` order: + * an explicitly-set `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` LOCKS that field + * so a `.hivemind` routing value can't override it. (`base` already folded the + * env var in via `loadConfig()`, so a locked field simply keeps `base`'s value.) + * `collect: false` is unaffected by env — it's a fail-safe opt-out. + * + * Tests may inject the two lock vars via `envOverride`; in production the vars + * are read as LITERAL `process.env.X` accesses (never an aliased `process.env`) + * so the openclaw esbuild `define` stubs them to `undefined` and the ClawHub + * env-harvesting scan stays clean — see the note in src/config.ts. + */ +export function resolveDirConfig( + base: Config, + cwd: string, + envOverride?: { HIVEMIND_ORG_ID?: string; HIVEMIND_WORKSPACE_ID?: string }, +): ResolvedDirConfig { + const found = findDirConfig(cwd); + if (!found) return { config: base, collect: true, found: null }; + + if (found.raw.collect === false) { + return { config: base, collect: false, found }; + } + const orgLocked = !!(envOverride ? envOverride.HIVEMIND_ORG_ID : process.env.HIVEMIND_ORG_ID); + const wsLocked = !!(envOverride ? envOverride.HIVEMIND_WORKSPACE_ID : process.env.HIVEMIND_WORKSPACE_ID); + const config: Config = { + ...base, + orgId: orgLocked ? base.orgId : (found.raw.orgId ?? base.orgId), + orgName: orgLocked ? base.orgName : (found.raw.orgName ?? found.raw.orgId ?? base.orgName), + workspaceId: wsLocked ? base.workspaceId : (found.raw.workspaceId ?? base.workspaceId), + }; + return { config, collect: true, found }; +} diff --git a/src/graph/deeplake-push.ts b/src/graph/deeplake-push.ts index 4e475725..3f9bfc14 100644 --- a/src/graph/deeplake-push.ts +++ b/src/graph/deeplake-push.ts @@ -39,6 +39,7 @@ import { createHash } from "node:crypto"; import { loadConfig, type Config } from "../config.js"; +import { resolveDirConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { sqlIdent, sqlStr } from "../utils/sql.js"; import type { GraphSnapshot } from "./types.js"; @@ -47,6 +48,7 @@ export type PushOutcome = | { kind: "skipped-no-auth" } | { kind: "skipped-no-commit" } | { kind: "skipped-disabled" } + | { kind: "skipped-collect-disabled" } | { kind: "inserted"; commitSha: string } | { kind: "inserted-with-duplicate-race"; commitSha: string; rowCount: number } | { kind: "already-current"; commitSha: string } @@ -58,6 +60,8 @@ export interface PushDeps { loadConfig?: () => Config | null; /** Override for tests; defaults to constructing a real DeeplakeApi. */ makeApi?: (config: Config) => DeeplakeApi; + /** Working directory for `.hivemind` resolution; defaults to process.cwd(). */ + cwd?: string; } /** @@ -72,10 +76,19 @@ export async function pushSnapshot( if (process.env.HIVEMIND_GRAPH_PUSH === "0") { return { kind: "skipped-disabled" }; } - const config = (deps.loadConfig ?? loadConfig)(); - if (config === null) { + const base = (deps.loadConfig ?? loadConfig)(); + if (base === null) { return { kind: "skipped-no-auth" }; } + // Per-directory `.hivemind`: skip the graph push where the repo opts out, + // and route to the configured org/workspace otherwise. Use the resolved + // build cwd (threaded from `runBuildCommand`), NOT process.cwd(), so + // `hivemind graph build --cwd ../repo` resolves the right tree's `.hivemind`. + const dirRes = resolveDirConfig(base, deps.cwd ?? process.cwd()); + if (!dirRes.collect) { + return { kind: "skipped-collect-disabled" }; + } + const config = dirRes.config; const commitSha = snapshot.graph.commit_sha; if (commitSha === null) { // CodeRabbit Minor: distinct outcome for "no commit context" — the diff --git a/src/hooks/capture.ts b/src/hooks/capture.ts index d182ca30..9f8768fe 100644 --- a/src/hooks/capture.ts +++ b/src/hooks/capture.ts @@ -8,7 +8,8 @@ */ import { readStdin } from "../utils/stdin.js"; -import { loadConfig, type Config } from "../config.js"; +import { type Config } from "../config.js"; +import { resolveCaptureConfig } from "./shared/dir-gate.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { sqlStr } from "../utils/sql.js"; import { projectNameFromCwd } from "../utils/project-name.js"; @@ -80,8 +81,10 @@ async function main(): Promise { if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } if (!entrypointPassesOnlyCliGate()) return; const input = await readStdin(); - const config = loadConfig(); - if (!config) { log("no config"); return; } + // Per-directory `.hivemind`: skip capture where opted out, and route to the + // configured org/workspace otherwise. + const config = resolveCaptureConfig(input.cwd ?? process.cwd(), log); + if (!config) return; // Self-heal the owner record for sessions that were already open before this // shipped (SessionStart only records it for new sessions). One /proc walk on diff --git a/src/hooks/codex/capture.ts b/src/hooks/codex/capture.ts index 76049c33..824f03a9 100644 --- a/src/hooks/codex/capture.ts +++ b/src/hooks/codex/capture.ts @@ -13,7 +13,8 @@ */ import { readStdin } from "../../utils/stdin.js"; -import { loadConfig, type Config } from "../../config.js"; +import { type Config } from "../../config.js"; +import { resolveCaptureConfig } from "../shared/dir-gate.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { sqlStr } from "../../utils/sql.js"; import { projectNameFromCwd } from "../../utils/project-name.js"; @@ -75,8 +76,8 @@ async function main(): Promise { if (!CAPTURE) return; if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); - const config = loadConfig(); - if (!config) { log("no config"); return; } + const config = resolveCaptureConfig(input.cwd ?? process.cwd(), log); + if (!config) return; const sessionsTable = config.sessionsTableName; const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, sessionsTable); diff --git a/src/hooks/codex/session-start-setup.ts b/src/hooks/codex/session-start-setup.ts index 61d6c686..b5fef14a 100644 --- a/src/hooks/codex/session-start-setup.ts +++ b/src/hooks/codex/session-start-setup.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; import { loadCredentials, saveCredentials } from "../../commands/auth.js"; import { loadConfig } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { readStdin } from "../../utils/stdin.js"; import { createPlaceholderSummary } from "../shared/placeholder-summary.js"; @@ -70,15 +71,21 @@ async function main(): Promise { const captureEnabled = process.env.HIVEMIND_CAPTURE !== "false"; if (input.session_id) { try { - const config = loadConfig(); - if (config) { - const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, config.tableName); - await api.ensureTable(); - await api.ensureSessionsTable(config.sessionsTableName); - if (captureEnabled) { + const base = loadConfig(); + if (base) { + const dirRes = resolveDirConfig(base, input.cwd ?? process.cwd()); + const config = dirRes.config; + if (captureEnabled && dirRes.collect) { + const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, config.tableName); + await api.ensureTable(); + await api.ensureSessionsTable(config.sessionsTableName); await createPlaceholder(api, config.tableName, input.session_id, input.cwd ?? "", config.userName, config.orgName, config.workspaceId); + log("setup complete"); + } else { + log(!dirRes.collect + ? `setup skipped — .hivemind collect:false (${dirRes.found?.path})` + : "setup skipped — HIVEMIND_CAPTURE=false"); } - log("setup complete"); } } catch (e: any) { log(`setup failed: ${e.message}`); diff --git a/src/hooks/codex/stop.ts b/src/hooks/codex/stop.ts index 26b3fb5b..a8526be6 100644 --- a/src/hooks/codex/stop.ts +++ b/src/hooks/codex/stop.ts @@ -16,6 +16,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { readStdin } from "../../utils/stdin.js"; import { loadConfig } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { sqlStr } from "../../utils/sql.js"; import { projectNameFromCwd } from "../../utils/project-name.js"; @@ -55,8 +56,11 @@ async function main(): Promise { const sessionId = input.session_id; if (!sessionId) return; - const config = loadConfig(); - if (!config) { log("no config"); return; } + const base = loadConfig(); + if (!base) { log("no config"); return; } + const dirRes = resolveDirConfig(base, input.cwd ?? process.cwd()); + if (!dirRes.collect) { log(`capture disabled for cwd=${input.cwd ?? "?"} via ${dirRes.found?.path}`); return; } + const config = dirRes.config; // 1. Capture the stop event (try to extract last assistant message from transcript) if (CAPTURE) { diff --git a/src/hooks/cursor/capture.ts b/src/hooks/cursor/capture.ts index 1b9a2ce7..cd7c5836 100644 --- a/src/hooks/cursor/capture.ts +++ b/src/hooks/cursor/capture.ts @@ -14,7 +14,7 @@ */ import { readStdin } from "../../utils/stdin.js"; -import { loadConfig } from "../../config.js"; +import { resolveCaptureConfig } from "../shared/dir-gate.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { sqlStr } from "../../utils/sql.js"; import { projectNameFromCwd } from "../../utils/project-name.js"; @@ -90,8 +90,8 @@ async function main(): Promise { if (!CAPTURE) return; if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); - const config = loadConfig(); - if (!config) { log("no config"); return; } + const config = resolveCaptureConfig(resolveCwd(input), log); + if (!config) return; const sessionId = input.conversation_id ?? `cursor-${Date.now()}`; const event = input.hook_event_name ?? ""; diff --git a/src/hooks/cursor/session-end.ts b/src/hooks/cursor/session-end.ts index 924a5999..5a665e8e 100644 --- a/src/hooks/cursor/session-end.ts +++ b/src/hooks/cursor/session-end.ts @@ -13,6 +13,7 @@ import { readStdin } from "../../utils/stdin.js"; import { log as _log } from "../../utils/debug.js"; import { loadConfig } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; import { tryAcquireLock } from "../summary-state.js"; import { bundleDirFromImportMeta, spawnCursorWikiWorker, wikiLog } from "./spawn-wiki-worker.js"; import { forceSessionEndTrigger } from "../../skillify/triggers.js"; @@ -33,8 +34,11 @@ async function main(): Promise { const sessionId = input.conversation_id ?? input.session_id ?? ""; log(`session=${sessionId || "?"} reason=${input.reason ?? "?"} status=${input.final_status ?? "?"}`); if (!sessionId) return; - const config = loadConfig(); - if (!config) { wikiLog(`SessionEnd: no config, skipping summary`); return; } + const base = loadConfig(); + if (!base) { wikiLog(`SessionEnd: no config, skipping summary`); return; } + const dirRes = resolveDirConfig(base, process.cwd()); + if (!dirRes.collect) { wikiLog(`SessionEnd: capture disabled for this directory (${dirRes.found?.path})`); return; } + const config = dirRes.config; // Skillify has its own per-project lock — fire before the wiki-worker lock // check so a Periodic trigger that already holds the lock doesn't suppress diff --git a/src/hooks/cursor/session-start.ts b/src/hooks/cursor/session-start.ts index af5a42f3..44ece3fa 100644 --- a/src/hooks/cursor/session-start.ts +++ b/src/hooks/cursor/session-start.ts @@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { loadCredentials, healDriftedOrgToken } from "../../commands/auth.js"; import { loadConfig } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { renderContextBlock } from "../shared/context-renderer.js"; import { createPlaceholderSummary } from "../shared/placeholder-summary.js"; @@ -146,21 +147,29 @@ async function main(): Promise { // + pass 4 together surfaced this layering: only writes (placeholder // + ensure DDL) are gated; reads (renderer) always run. const captureEnabled = process.env.HIVEMIND_CAPTURE !== "false"; + + // Per-directory `.hivemind`: route / opt out for this tree. Resolved once and + // reused for the placeholder write and the disclosure banner below. + const baseConfig = loadConfig(); + const dirRes = baseConfig ? resolveDirConfig(baseConfig, cwd) : null; + const collectHere = captureEnabled && (dirRes?.collect ?? true); let rulesBlock = ""; if (creds?.token) { try { - const config = loadConfig(); + const config = dirRes?.config; if (config) { const table = config.tableName; const sessionsTable = config.sessionsTableName; const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, table); - if (captureEnabled) { + if (collectHere) { await api.ensureTable(); await api.ensureSessionsTable(sessionsTable); await createPlaceholder(api, table, sessionId, cwd, config.userName, config.orgName, config.workspaceId, pluginVersion); log("placeholder created"); } else { - log("placeholder + schema ensure skipped (HIVEMIND_CAPTURE=false)"); + log(dirRes && !dirRes.collect + ? `placeholder + schema ensure skipped (.hivemind collect:false ${dirRes.found?.path})` + : "placeholder + schema ensure skipped (HIVEMIND_CAPTURE=false)"); } // Read-only renderer. Cursor's additional_context is invisible // to the user (model-only), so the full block is fine. Renderer @@ -208,8 +217,17 @@ async function main(): Promise { // (pullSnapshot would early-return skipped-no-auth anyway). if (creds?.token) spawnGraphPullWorker(resolveCwd(input), __bundleDir); + // Disclose the EFFECTIVE identity (after any `.hivemind` overlay). + const effConfig = dirRes?.config ?? baseConfig; + const routed = !!(dirRes?.found && dirRes.collect && baseConfig && + (dirRes.config.orgId !== baseConfig.orgId || dirRes.config.workspaceId !== baseConfig.workspaceId)); + const effOrg = effConfig ? (effConfig.orgName ?? effConfig.orgId) : (creds?.orgName ?? creds?.orgId); + const effWs = effConfig ? effConfig.workspaceId : (creds?.workspaceId ?? "default"); + const identityLine = dirRes && !dirRes.collect + ? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search still uses org: ${effOrg}` + : `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routed ? ` · routed by ${dirRes?.found?.path}` : ""}`; const baseContext = creds?.token - ? `${context}\nLogged in to Deeplake as org: ${creds.orgName ?? creds.orgId} (workspace: ${creds.workspaceId ?? "default"})${versionNotice}` + ? `${context}\n${identityLine}${versionNotice}` : `${context}\nNot logged in to Deeplake. Run: hivemind login${localMinedNote}${versionNotice}`; // Cursor cannot route Write/Edit through hivemind hooks (its // pre-tool-use only intercepts Shell). So the agent here uses diff --git a/src/hooks/hermes/capture.ts b/src/hooks/hermes/capture.ts index 04df6403..836b22f4 100644 --- a/src/hooks/hermes/capture.ts +++ b/src/hooks/hermes/capture.ts @@ -15,7 +15,7 @@ */ import { readStdin } from "../../utils/stdin.js"; -import { loadConfig } from "../../config.js"; +import { resolveCaptureConfig } from "../shared/dir-gate.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { sqlStr } from "../../utils/sql.js"; import { projectNameFromCwd } from "../../utils/project-name.js"; @@ -79,8 +79,8 @@ async function main(): Promise { if (!CAPTURE) return; if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); - const config = loadConfig(); - if (!config) { log("no config"); return; } + const config = resolveCaptureConfig(input.cwd ?? process.cwd(), log); + if (!config) return; const sessionId = input.session_id ?? `hermes-${Date.now()}`; const event = input.hook_event_name ?? ""; diff --git a/src/hooks/hermes/session-end.ts b/src/hooks/hermes/session-end.ts index a0b31454..418bb525 100644 --- a/src/hooks/hermes/session-end.ts +++ b/src/hooks/hermes/session-end.ts @@ -8,6 +8,7 @@ import { readStdin } from "../../utils/stdin.js"; import { log as _log } from "../../utils/debug.js"; import { loadConfig } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; import { tryAcquireLock } from "../summary-state.js"; import { bundleDirFromImportMeta, spawnHermesWikiWorker, wikiLog } from "./spawn-wiki-worker.js"; import { forceSessionEndTrigger } from "../../skillify/triggers.js"; @@ -26,8 +27,11 @@ async function main(): Promise { const sessionId = input.session_id ?? ""; log(`session=${sessionId || "?"} cwd=${input.cwd ?? "?"}`); if (!sessionId) return; - const config = loadConfig(); - if (!config) { wikiLog(`SessionEnd: no config, skipping summary`); return; } + const base = loadConfig(); + if (!base) { wikiLog(`SessionEnd: no config, skipping summary`); return; } + const dirRes = resolveDirConfig(base, input.cwd ?? process.cwd()); + if (!dirRes.collect) { wikiLog(`SessionEnd: capture disabled for this directory (${dirRes.found?.path})`); return; } + const config = dirRes.config; const cwd = input.cwd ?? process.cwd(); // Skillify has its own per-project lock — fire before the wiki-worker lock diff --git a/src/hooks/hermes/session-start.ts b/src/hooks/hermes/session-start.ts index a44c01c4..65a54661 100644 --- a/src/hooks/hermes/session-start.ts +++ b/src/hooks/hermes/session-start.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { loadCredentials, healDriftedOrgToken } from "../../commands/auth.js"; import { loadConfig } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { renderContextBlock } from "../shared/context-renderer.js"; import { createPlaceholderSummary } from "../shared/placeholder-summary.js"; @@ -95,6 +96,12 @@ async function main(): Promise { let creds = loadCredentials(); const captureEnabled = process.env.HIVEMIND_CAPTURE !== "false"; + // Per-directory `.hivemind`: route / opt out for this tree. Resolved once and + // reused for the placeholder write and the disclosure banner below. + const baseConfig = loadConfig(); + const dirRes = baseConfig ? resolveDirConfig(baseConfig, cwd) : null; + const collectHere = captureEnabled && (dirRes?.collect ?? true); + if (!creds?.token) { // Auto-trigger mine-local on first SessionStart for unauthenticated // users. Detached spawn — see spawn-mine-local-worker.ts for the @@ -123,16 +130,18 @@ async function main(): Promise { let rulesBlock = ""; if (creds?.token) { try { - const config = loadConfig(); + const config = dirRes?.config; if (config) { const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, config.tableName); - if (captureEnabled) { + if (collectHere) { await api.ensureTable(); await api.ensureSessionsTable(config.sessionsTableName); await createPlaceholder(api, config.tableName, sessionId, cwd, config.userName, config.orgName, config.workspaceId, pluginVersion); log("placeholder created"); } else { - log("placeholder + schema ensure skipped (HIVEMIND_CAPTURE=false)"); + log(dirRes && !dirRes.collect + ? `placeholder + schema ensure skipped (.hivemind collect:false ${dirRes.found?.path})` + : "placeholder + schema ensure skipped (HIVEMIND_CAPTURE=false)"); } // Read-only renderer. Hermes's context field is invisible to // the user (model-only). Renderer absorbs its own errors. @@ -178,8 +187,17 @@ async function main(): Promise { // (pullSnapshot would early-return skipped-no-auth anyway). if (creds?.token) spawnGraphPullWorker(cwd, __bundleDir); + // Disclose the EFFECTIVE identity (after any `.hivemind` overlay). + const effConfig = dirRes?.config ?? baseConfig; + const routed = !!(dirRes?.found && dirRes.collect && baseConfig && + (dirRes.config.orgId !== baseConfig.orgId || dirRes.config.workspaceId !== baseConfig.workspaceId)); + const effOrg = effConfig ? (effConfig.orgName ?? effConfig.orgId) : (creds?.orgName ?? creds?.orgId); + const effWs = effConfig ? effConfig.workspaceId : (creds?.workspaceId ?? "default"); + const identityLine = dirRes && !dirRes.collect + ? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search still uses org: ${effOrg}` + : `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routed ? ` · routed by ${dirRes?.found?.path}` : ""}`; const baseContext = creds?.token - ? `${context}\nLogged in to Deeplake as org: ${creds.orgName ?? creds.orgId} (workspace: ${creds.workspaceId ?? "default"})${versionNotice}` + ? `${context}\n${identityLine}${versionNotice}` : `${context}\nNot logged in to Deeplake. Run: hivemind login${localMinedNote}${versionNotice}`; // Hermes' pre-tool-use intercepts only `terminal` — it cannot // route Write/Edit. Use the CLI variant: agent invokes diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index 97a0dbff..98d01d74 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -10,6 +10,7 @@ import { readStdin } from "../utils/stdin.js"; import { loadConfig, type Config } from "../config.js"; +import { resolveDirConfig } from "../dir-config.js"; import { log as _log } from "../utils/debug.js"; import { bundleDirFromImportMeta, spawnWikiWorker, wikiLog } from "./spawn-wiki-worker.js"; import { tryAcquireLock, releaseLock, markSessionEnded } from "./summary-state.js"; @@ -66,8 +67,18 @@ async function main(): Promise { // the activity window to lapse). Independent of the wiki-worker lock below. markSessionEnded(sessionId); - const config = loadConfig(); - if (!config) { log("no config"); return; } + const base = loadConfig(); + if (!base) { log("no config"); return; } + + // Per-directory `.hivemind`: honor opt-out and trusted org/workspace routing, + // matching the capture hook so the end-of-session summary lands (or doesn't) + // in the same place its events did. + const resolved = resolveDirConfig(base, cwd || process.cwd()); + if (!resolved.collect) { + log(`session-end capture disabled for cwd=${cwd || "?"} via ${resolved.found?.path}`); + return; + } + const config = resolved.config; // Record memory-search activity for the savings recap. Independent of the // wiki-worker lock below: even sessions where the wiki worker can't run diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index bf9b2508..cc71ec92 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -14,6 +14,7 @@ import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { loadCredentials, saveCredentials, healDriftedOrgToken } from "../commands/auth.js"; import { loadConfig } from "../config.js"; +import { resolveDirConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { readStdin } from "../utils/stdin.js"; import { log as _log } from "../utils/debug.js"; @@ -195,6 +196,15 @@ async function main(): Promise { // CLI write path (`hivemind rules add`). const captureEnabled = process.env.HIVEMIND_CAPTURE !== "false" && entrypointPassesOnlyCliGate(); + // Per-directory `.hivemind`: route this tree's traces to a configured + // org/workspace, or opt out entirely (`collect: false`). Resolved once and + // reused for the placeholder write below and the disclosure banner. Falls + // back to the global identity when no `.hivemind` applies. + const sessionCwd = input.cwd ?? process.cwd(); + const baseConfig = loadConfig(); + const dirRes = baseConfig ? resolveDirConfig(baseConfig, sessionCwd) : null; + const collectHere = captureEnabled && (dirRes?.collect ?? true); + // Auto-pull skills from all org users into ~/.claude/skills/ on every // SessionStart. File writes inside runPull are idempotent (skipped // when local version is at-or-newer than remote), so re-running each @@ -209,20 +219,22 @@ async function main(): Promise { let rulesBlock = ""; if (input.session_id && creds?.token) { try { - const config = loadConfig(); + const config = dirRes?.config; if (config) { const table = config.tableName; const sessionsTable = config.sessionsTableName; const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, table); - if (captureEnabled) { + if (collectHere) { await api.ensureTable(); await api.ensureSessionsTable(sessionsTable); - await createPlaceholder(api, table, input.session_id, input.cwd ?? "", config.userName, config.orgName, config.workspaceId, pluginVersion); + await createPlaceholder(api, table, input.session_id, sessionCwd, config.userName, config.orgName, config.workspaceId, pluginVersion); log("placeholder created"); } else { - const reason = process.env.HIVEMIND_CAPTURE === "false" - ? "HIVEMIND_CAPTURE=false" - : "HIVEMIND_CAPTURE_ONLY_CLI gate"; + const reason = dirRes && !dirRes.collect + ? `.hivemind collect:false (${dirRes.found?.path})` + : process.env.HIVEMIND_CAPTURE === "false" + ? "HIVEMIND_CAPTURE=false" + : "HIVEMIND_CAPTURE_ONLY_CLI gate"; log(`placeholder + schema ensure skipped (${reason})`); } // Docs auto sync check — the "every so often" the summary worker has. @@ -318,8 +330,18 @@ async function main(): Promise { ? docsWikiContextNote(creds.orgId ?? "", deriveProjectKey(input.cwd ?? process.cwd()).key) : ""; + // Disclose the EFFECTIVE identity (after any `.hivemind` overlay), so a + // directory that routes elsewhere (or opts out) is never silent. + const effConfig = dirRes?.config ?? baseConfig; + const routed = !!(dirRes?.found && dirRes.collect && baseConfig && + (dirRes.config.orgId !== baseConfig.orgId || dirRes.config.workspaceId !== baseConfig.workspaceId)); + const effOrg = effConfig ? (effConfig.orgName ?? effConfig.orgId) : (creds?.orgName ?? creds?.orgId); + const effWs = effConfig ? effConfig.workspaceId : (creds?.workspaceId ?? "default"); + const identityLine = dirRes && !dirRes.collect + ? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search still uses org: ${effOrg}` + : `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routed ? ` · routed by ${dirRes?.found?.path}` : ""}`; const baseContext = creds?.token - ? `${resolvedContext}\n\nLogged in to Deeplake as org: ${creds.orgName ?? creds.orgId} (workspace: ${creds.workspaceId ?? "default"})${updateNotice}` + ? `${resolvedContext}\n\n${identityLine}${updateNotice}` : `${resolvedContext}\n\nNot logged in to Deeplake; memory search is unavailable this session.${localMinedNote}${updateNotice}`; // Append the rules block when there's something to show, then // append the graph note (single line, may be empty). The renderer diff --git a/src/hooks/shared/dir-gate.ts b/src/hooks/shared/dir-gate.ts new file mode 100644 index 00000000..eeb08429 --- /dev/null +++ b/src/hooks/shared/dir-gate.ts @@ -0,0 +1,27 @@ +/** + * Shared per-directory capture gate for all agent capture hooks + * (claude-code / codex / cursor / hermes). + * + * Loads the global config and overlays the nearest `.hivemind` for `cwd` + * (see src/dir-config.ts). Returns the Config to capture with, or `null` when + * capture should be skipped — either because there's no auth, or the directory + * opted out via `collect: false`. The skip reason is logged. + */ + +import { loadConfig, type Config } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; + +export function resolveCaptureConfig(cwd: string, log: (msg: string) => void): Config | null { + const base = loadConfig(); + if (!base) { + log("no config"); + return null; + } + const effCwd = cwd || process.cwd(); + const resolved = resolveDirConfig(base, effCwd); + if (!resolved.collect) { + log(`capture disabled for cwd=${effCwd} via ${resolved.found?.path}`); + return null; + } + return resolved.config; +} diff --git a/tests/claude-code/capture-hook.test.ts b/tests/claude-code/capture-hook.test.ts index 00172906..822591cc 100644 --- a/tests/claude-code/capture-hook.test.ts +++ b/tests/claude-code/capture-hook.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; /** * Direct source-level tests for src/hooks/capture.ts. The module runs @@ -27,6 +30,7 @@ const ensureSessionOwnerMock = vi.fn(); const debugLogMock = vi.fn(); const queryMock = vi.fn(); const ensureSessionsTableMock = vi.fn(); +const apiCtorMock = vi.fn(); vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...a: any[]) => stdinMock(...a) })); vi.mock("../../src/config.js", () => ({ loadConfig: (...a: any[]) => loadConfigMock(...a) })); @@ -48,6 +52,7 @@ vi.mock("../../src/utils/debug.js", () => ({ })); vi.mock("../../src/deeplake-api.js", () => ({ DeeplakeApi: class { + constructor(...args: any[]) { apiCtorMock(...args); } query(sql: string) { return queryMock(sql); } ensureSessionsTable(t: string) { return ensureSessionsTableMock(t); } }, @@ -98,6 +103,7 @@ beforeEach(() => { debugLogMock.mockReset(); queryMock.mockReset().mockResolvedValue([]); ensureSessionsTableMock.mockReset().mockResolvedValue(undefined); + apiCtorMock.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); @@ -117,6 +123,63 @@ describe("capture hook — guard", () => { }); }); +describe("capture hook — per-directory .hivemind", () => { + let hmDir: string; + + afterEach(() => { + if (hmDir) rmSync(hmDir, { recursive: true, force: true }); + }); + + function withHivemind(body: Record): void { + hmDir = mkdtempSync(join(tmpdir(), "capture-hivemind-")); + writeFileSync(join(hmDir, ".hivemind"), JSON.stringify(body)); + stdinMock.mockResolvedValue({ + session_id: "sid-1", + cwd: hmDir, + hook_event_name: "UserPromptSubmit", + prompt: "hello", + }); + } + + it("collect:false writes nothing for this directory", async () => { + withHivemind({ collect: false }); + await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + expect(queryMock).not.toHaveBeenCalled(); + expect(apiCtorMock).not.toHaveBeenCalled(); + }); + + it("a routing .hivemind constructs the API against the routed org/workspace", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + expect(queryMock).toHaveBeenCalledTimes(1); + // DeeplakeApi(token, apiUrl, orgId, workspaceId, table) + const args = apiCtorMock.mock.calls[0]; + expect(args[2]).toBe("routed-org"); + expect(args[3]).toBe("routed-ws"); + }); + + it("env HIVEMIND_ORG_ID overrides the routed org (env > file)", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + loadConfigMock.mockReturnValue({ ...validConfig, orgId: "env-org", orgName: "env-org" }); + await runHook({ HIVEMIND_ORG_ID: "env-org", HIVEMIND_WORKSPACE_ID: undefined }); + const args = apiCtorMock.mock.calls[0]; + expect(args[2]).toBe("env-org"); // env wins for org + expect(args[3]).toBe("routed-ws"); // workspace still routes + }); + + it("resolves against process.cwd() when the hook passes an empty cwd", async () => { + // Exercises resolveCaptureConfig's `cwd || process.cwd()` fallback. The + // repo root has no `.hivemind`, so capture proceeds against the global org. + stdinMock.mockResolvedValue({ + session_id: "sid-1", cwd: "", hook_event_name: "UserPromptSubmit", prompt: "hello", + }); + await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + expect(queryMock).toHaveBeenCalledTimes(1); + // The skip log (if it fired) prints the resolved cwd, never a bare "?". + expect(debugLogMock).not.toHaveBeenCalledWith(expect.stringContaining("cwd=? ")); + }); +}); + describe("capture hook — event-type branches", () => { it("user_message: INSERT contains prompt content", async () => { await runHook(); diff --git a/tests/claude-code/session-start-hook.test.ts b/tests/claude-code/session-start-hook.test.ts index 8a12f9cf..68bb04d9 100644 --- a/tests/claude-code/session-start-hook.test.ts +++ b/tests/claude-code/session-start-hook.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -197,14 +197,66 @@ describe("session-start hook — guards", () => { }); it("falls back to orgId when orgName is missing", async () => { + // The banner reflects the effective (resolved) config; real loadConfig + // sets orgName = orgId when creds lack an orgName, so drive that here. loadCredsMock.mockReturnValue({ token: "t", orgId: "org-uuid", userName: "u", workspaceId: "default", }); + loadConfigMock.mockReturnValue({ ...validConfig, orgId: "org-uuid", orgName: undefined }); const out = await runHook(); const parsed = JSON.parse(out!); expect(parsed.hookSpecificOutput.additionalContext).toContain("Logged in to Deeplake as org: org-uuid"); }); + describe("per-directory .hivemind wiring", () => { + let hmDir: string; + + afterEach(() => { + if (hmDir) rmSync(hmDir, { recursive: true, force: true }); + }); + + function withHivemind(body: Record): void { + hmDir = mkdtempSync(join(tmpdir(), "session-start-hivemind-")); + writeFileSync(join(hmDir, ".hivemind"), JSON.stringify(body)); + stdinMock.mockResolvedValue({ session_id: "sid-1", cwd: hmDir }); + } + + it("a routing .hivemind sends the placeholder to the routed org and discloses it in the banner", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + const out = await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + const ctx = JSON.parse(out!).hookSpecificOutput.additionalContext; + expect(ctx).toContain("org: routed-org (workspace: routed-ws)"); + expect(ctx).toContain("routed by"); + // Capture still happens (routed, not opted out) → tables ensured. + expect(ensureTableMock).toHaveBeenCalled(); + }); + + it("collect:false skips the placeholder/table setup and says capture is disabled", async () => { + withHivemind({ collect: false }); + const out = await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + const ctx = JSON.parse(out!).hookSpecificOutput.additionalContext; + expect(ctx).toContain("capture is disabled for this directory"); + // No capture → no DDL and no placeholder INSERT for this directory. + expect(ensureTableMock).not.toHaveBeenCalled(); + expect(ensureSessionsTableMock).not.toHaveBeenCalled(); + }); + + it("env HIVEMIND_ORG_ID overrides a routing .hivemind (env > file)", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + // base already reflects the env-pinned org (loadConfig folds it in). + loadConfigMock.mockReturnValue({ ...validConfig, orgId: "env-org", orgName: "env-org" }); + loadCredsMock.mockReturnValue({ + token: "tok", orgId: "env-org", orgName: "env-org", userName: "alice", workspaceId: "default", + }); + const out = await runHook({ HIVEMIND_ORG_ID: "env-org", HIVEMIND_WORKSPACE_ID: undefined }); + const ctx = JSON.parse(out!).hookSpecificOutput.additionalContext; + expect(ctx).toContain("org: env-org"); + expect(ctx).not.toContain("routed-org"); + // workspace isn't env-pinned, so the file still routes it. + expect(ctx).toContain("workspace: routed-ws"); + }); + }); + it("backfills userName via node:os when credentials lack one", async () => { loadCredsMock.mockReturnValue({ token: "t", orgId: "o", orgName: "acme", workspaceId: "default", diff --git a/tests/codex/codex-session-start-setup-hook.test.ts b/tests/codex/codex-session-start-setup-hook.test.ts index ae8ce581..42777797 100644 --- a/tests/codex/codex-session-start-setup-hook.test.ts +++ b/tests/codex/codex-session-start-setup-hook.test.ts @@ -134,10 +134,12 @@ describe("codex session-start-setup hook — placeholder branching", () => { expect(queryMock).toHaveBeenCalledTimes(1); }); - it("skips placeholder when HIVEMIND_CAPTURE=false but still ensures tables", async () => { + it("skips all backend setup when HIVEMIND_CAPTURE=false", async () => { + // Setup writes (ensure DDL + placeholder) are gated on captureEnabled && + // collect, matching the claude hook — nothing touches the backend. await runHook({ HIVEMIND_CAPTURE: "false" }); - expect(ensureTableMock).toHaveBeenCalled(); - expect(ensureSessionsTableMock).toHaveBeenCalled(); + expect(ensureTableMock).not.toHaveBeenCalled(); + expect(ensureSessionsTableMock).not.toHaveBeenCalled(); expect(queryMock).not.toHaveBeenCalled(); }); diff --git a/tests/cursor/cursor-session-start-hook.test.ts b/tests/cursor/cursor-session-start-hook.test.ts index 27d514bf..befb7fd3 100644 --- a/tests/cursor/cursor-session-start-hook.test.ts +++ b/tests/cursor/cursor-session-start-hook.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const stdinMock = vi.fn(); const loadConfigMock = vi.fn(); @@ -175,6 +178,7 @@ describe("cursor session-start hook — additional_context payload", () => { it("falls back to orgId in the org-line when orgName is missing", async () => { loadCredentialsMock.mockReturnValue({ token: "t", orgId: "o-99" }); + loadConfigMock.mockReturnValue({ ...validConfig, orgId: "o-99", orgName: undefined }); await runHook(); const payload = JSON.parse(consoleLogMock.mock.calls[0][0] as string); expect(payload.additional_context).toContain("org: o-99"); @@ -253,3 +257,43 @@ describe("cursor session-start hook — local mined skills note", () => { expect(debugLogMock).toHaveBeenCalledWith(expect.stringContaining("auto-mine: triggered")); }); }); + +describe("cursor session-start hook — per-directory .hivemind", () => { + let hmDir: string; + afterEach(() => { if (hmDir) rmSync(hmDir, { recursive: true, force: true }); }); + + function withHivemind(body: Record): void { + hmDir = mkdtempSync(join(tmpdir(), "cursor-hivemind-")); + writeFileSync(join(hmDir, ".hivemind"), JSON.stringify(body)); + stdinMock.mockResolvedValue({ session_id: "sid-1", workspace_roots: [hmDir] }); + } + + function banner(): string { + return JSON.parse(consoleLogMock.mock.calls[0][0] as string).additional_context; + } + + it("a routing .hivemind discloses the routed org/workspace in the banner", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + expect(banner()).toContain("org: routed-org (workspace: routed-ws)"); + expect(banner()).toContain("routed by"); + expect(ensureTableMock).toHaveBeenCalled(); + }); + + it("collect:false skips table setup and says capture is disabled", async () => { + withHivemind({ collect: false }); + await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + expect(banner()).toContain("capture is disabled for this directory"); + expect(ensureTableMock).not.toHaveBeenCalled(); + expect(ensureSessionsTableMock).not.toHaveBeenCalled(); + }); + + it("env HIVEMIND_ORG_ID overrides a routing .hivemind (env > file)", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + loadConfigMock.mockReturnValue({ ...validConfig, orgId: "env-org", orgName: "env-org" }); + await runHook({ HIVEMIND_ORG_ID: "env-org", HIVEMIND_WORKSPACE_ID: undefined }); + expect(banner()).toContain("org: env-org"); + expect(banner()).not.toContain("routed-org"); + expect(banner()).toContain("workspace: routed-ws"); + }); +}); diff --git a/tests/hermes/hermes-session-start-hook.test.ts b/tests/hermes/hermes-session-start-hook.test.ts index 16710416..59cddbc5 100644 --- a/tests/hermes/hermes-session-start-hook.test.ts +++ b/tests/hermes/hermes-session-start-hook.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const stdinMock = vi.fn(); const loadConfigMock = vi.fn(); @@ -177,6 +180,7 @@ describe("hermes session-start hook — context payload", () => { it("falls back to orgId in the org line when orgName is missing", async () => { loadCredentialsMock.mockReturnValue({ token: "t", orgId: "o-99" }); + loadConfigMock.mockReturnValue({ ...validConfig, orgId: "o-99", orgName: undefined }); await runHook(); const payload = JSON.parse(consoleLogMock.mock.calls[0][0] as string); expect(payload.context).toContain("org: o-99"); @@ -261,3 +265,43 @@ describe("hermes session-start hook — local mined skills note", () => { expect(insertSql).toBeTruthy(); }); }); + +describe("hermes session-start hook — per-directory .hivemind", () => { + let hmDir: string; + afterEach(() => { if (hmDir) rmSync(hmDir, { recursive: true, force: true }); }); + + function withHivemind(body: Record): void { + hmDir = mkdtempSync(join(tmpdir(), "hermes-hivemind-")); + writeFileSync(join(hmDir, ".hivemind"), JSON.stringify(body)); + stdinMock.mockResolvedValue({ session_id: "ses-1", cwd: hmDir }); + } + + function banner(): string { + return JSON.parse(consoleLogMock.mock.calls[0][0] as string).context; + } + + it("a routing .hivemind discloses the routed org/workspace in the banner", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + expect(banner()).toContain("org: routed-org (workspace: routed-ws)"); + expect(banner()).toContain("routed by"); + expect(ensureTableMock).toHaveBeenCalled(); + }); + + it("collect:false skips table setup and says capture is disabled", async () => { + withHivemind({ collect: false }); + await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); + expect(banner()).toContain("capture is disabled for this directory"); + expect(ensureTableMock).not.toHaveBeenCalled(); + expect(ensureSessionsTableMock).not.toHaveBeenCalled(); + }); + + it("env HIVEMIND_ORG_ID overrides a routing .hivemind (env > file)", async () => { + withHivemind({ orgId: "routed-org", workspaceId: "routed-ws" }); + loadConfigMock.mockReturnValue({ ...validConfig, orgId: "env-org", orgName: "env-org" }); + await runHook({ HIVEMIND_ORG_ID: "env-org", HIVEMIND_WORKSPACE_ID: undefined }); + expect(banner()).toContain("org: env-org"); + expect(banner()).not.toContain("routed-org"); + expect(banner()).toContain("workspace: routed-ws"); + }); +}); diff --git a/tests/pi/pi-extension-source.test.ts b/tests/pi/pi-extension-source.test.ts index 7ba7f5aa..37552f36 100644 --- a/tests/pi/pi-extension-source.test.ts +++ b/tests/pi/pi-extension-source.test.ts @@ -281,3 +281,35 @@ describe("pi extension — SkillOpt wiring", () => { expect(PI_SRC).toContain('ref.lastIndexOf("--")'); // require name--author }); }); + +// Pi is a standalone extension (no shared-module imports), so it carries an +// inline mirror of src/dir-config.ts. These guard that the per-directory +// `.hivemind` wiring (opt-out + routing) is present and applied on every +// capture path — the gap Emanuele flagged on PR #302. +describe("pi extension — per-directory .hivemind wiring", () => { + it("defines the inline resolver over both filenames", () => { + expect(PI_SRC).toContain("function findHivemindDir"); + expect(PI_SRC).toContain("function applyDirConfig"); + expect(PI_SRC).toContain('".hivemind.local", ".hivemind"'); + }); + + it("honors env precedence (env > file) in the overlay", () => { + expect(PI_SRC).toContain("process.env.HIVEMIND_ORG_ID"); + expect(PI_SRC).toContain("process.env.HIVEMIND_WORKSPACE_ID"); + }); + + it("gates every capture path on the resolved collect flag", () => { + // input / tool_result / message_end / session_shutdown each skip on opt-out. + const skips = PI_SRC.match(/capture disabled for cwd=\$\{cwd\} via \.hivemind/g) ?? []; + expect(skips.length).toBe(4); + // and each reassigns creds to the routed identity after the gate. + const reassigns = PI_SRC.match(/creds = dirRes\.creds;/g) ?? []; + expect(reassigns.length).toBe(4); + }); + + it("gates session_start table setup on collect and discloses routing", () => { + expect(PI_SRC).toContain("creds && captureEnabled && dirCollect"); + expect(PI_SRC).toContain("routed by .hivemind"); + expect(PI_SRC).toContain("capture is disabled for this directory"); + }); +}); diff --git a/tests/shared/dir-config.test.ts b/tests/shared/dir-config.test.ts new file mode 100644 index 00000000..055178de --- /dev/null +++ b/tests/shared/dir-config.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Config } from "../../src/config.js"; +import { + findDirConfig, + parseDirConfig, + resolveDirConfig, +} from "../../src/dir-config.js"; + +let root: string; + +function base(): Config { + return { + token: "tok", + orgId: "global-org", + orgName: "global", + userName: "u", + workspaceId: "default", + apiUrl: "https://api.deeplake.ai", + tableName: "memory", + sessionsTableName: "sessions", + skillsTableName: "skills", + rulesTableName: "hivemind_rules", + goalsTableName: "hivemind_goals", + kpisTableName: "hivemind_kpis", + codebaseTableName: "codebase", + docsTableName: "docs", + memoryPath: "/tmp/mem", + }; +} + +/** mkdir -p under the sandbox root and return the absolute path. */ +function dir(...segs: string[]): string { + const p = join(root, ...segs); + mkdirSync(p, { recursive: true }); + return p; +} + +function write(dirPath: string, name: string, body: unknown): void { + writeFileSync(join(dirPath, name), typeof body === "string" ? body : JSON.stringify(body)); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "hivemind-dir-config-")); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("parseDirConfig", () => { + it("whitelists known fields and ignores the rest", () => { + expect( + parseDirConfig(JSON.stringify({ orgId: "a", workspaceId: "w", collect: false, junk: 1, token: "x" })), + ).toEqual({ orgId: "a", workspaceId: "w", collect: false }); + }); + + it("drops fields of the wrong type", () => { + expect(parseDirConfig(JSON.stringify({ orgId: 5, collect: "no" }))).toEqual({}); + }); + + it("returns null for malformed JSON, arrays, and non-objects", () => { + expect(parseDirConfig("{not json")).toBeNull(); + expect(parseDirConfig("[1,2]")).toBeNull(); + expect(parseDirConfig('"a string"')).toBeNull(); + expect(parseDirConfig("null")).toBeNull(); + }); +}); + +describe("findDirConfig", () => { + it("returns null when no file exists up to the boundary", () => { + const leaf = dir("a", "b"); + expect(findDirConfig(leaf, root)).toBeNull(); + }); + + it("finds a file in an ancestor directory (walk up)", () => { + write(dir("proj"), ".hivemind", { orgId: "acme" }); + const found = findDirConfig(dir("proj", "svc", "deep"), root); + expect(found?.raw.orgId).toBe("acme"); + expect(found?.path).toBe(join(root, "proj", ".hivemind")); + }); + + it("nearest directory wins over an ancestor", () => { + write(dir("proj"), ".hivemind", { orgId: "outer" }); + write(dir("proj", "svc"), ".hivemind", { orgId: "inner" }); + const found = findDirConfig(dir("proj", "svc", "x"), root); + expect(found?.raw.orgId).toBe("inner"); + }); + + it("prefers .hivemind.local over .hivemind in the same directory", () => { + const d = dir("proj"); + write(d, ".hivemind", { orgId: "committed" }); + write(d, ".hivemind.local", { orgId: "personal" }); + expect(findDirConfig(d, root)?.raw.orgId).toBe("personal"); + }); + + it("skips an unparseable file and keeps walking up", () => { + write(dir("proj"), ".hivemind", { orgId: "valid-ancestor" }); + write(dir("proj", "svc"), ".hivemind", "{ broken json"); + const found = findDirConfig(dir("proj", "svc"), root); + expect(found?.raw.orgId).toBe("valid-ancestor"); + }); +}); + +describe("resolveDirConfig", () => { + it("passes through the global identity when no .hivemind applies", () => { + const res = resolveDirConfig(base(), dir("empty")); + expect(res.collect).toBe(true); + expect(res.found).toBeNull(); + expect(res.config.orgId).toBe("global-org"); + }); + + it("overlays org + workspace, leaving unset fields on the global default", () => { + write(dir("proj"), ".hivemind", { orgId: "acme", orgName: "Acme" }); + const res = resolveDirConfig(base(), dir("proj")); + expect(res.collect).toBe(true); + expect(res.config.orgId).toBe("acme"); + expect(res.config.orgName).toBe("Acme"); + expect(res.config.workspaceId).toBe("default"); // untouched + }); + + it("derives orgName from orgId when only orgId is given", () => { + write(dir("proj"), ".hivemind", { orgId: "acme" }); + expect(resolveDirConfig(base(), dir("proj")).config.orgName).toBe("acme"); + }); + + it("routes the workspace independently of the org", () => { + write(dir("proj"), ".hivemind", { workspaceId: "client-work" }); + const res = resolveDirConfig(base(), dir("proj")); + expect(res.config.orgId).toBe("global-org"); + expect(res.config.workspaceId).toBe("client-work"); + }); + + it("suppresses capture on collect:false without altering the config", () => { + write(dir("proj"), ".hivemind", { collect: false }); + const res = resolveDirConfig(base(), dir("proj")); + expect(res.collect).toBe(false); + expect(res.config.orgId).toBe("global-org"); + expect(res.found?.path).toBe(join(root, "proj", ".hivemind")); + }); + + it("collect:false wins even when org/workspace are also present", () => { + write(dir("proj"), ".hivemind", { orgId: "acme", collect: false }); + const res = resolveDirConfig(base(), dir("proj")); + expect(res.collect).toBe(false); + }); +}); + +describe("resolveDirConfig — env precedence (env > .hivemind)", () => { + it("HIVEMIND_ORG_ID locks the org, but the workspace still routes", () => { + write(dir("proj"), ".hivemind", { orgId: "acme", workspaceId: "client-work" }); + // base already reflects the env-pinned org (loadConfig folds it in). + const pinned = { ...base(), orgId: "env-org", orgName: "env-org" }; + const res = resolveDirConfig(pinned, dir("proj"), { HIVEMIND_ORG_ID: "env-org" }); + expect(res.config.orgId).toBe("env-org"); // .hivemind org ignored + expect(res.config.workspaceId).toBe("client-work"); // workspace still routes + }); + + it("HIVEMIND_WORKSPACE_ID locks the workspace, but the org still routes", () => { + write(dir("proj"), ".hivemind", { orgId: "acme", workspaceId: "client-work" }); + const pinned = { ...base(), workspaceId: "env-ws" }; + const res = resolveDirConfig(pinned, dir("proj"), { HIVEMIND_WORKSPACE_ID: "env-ws" }); + expect(res.config.orgId).toBe("acme"); // org still routes + expect(res.config.workspaceId).toBe("env-ws"); // .hivemind workspace ignored + }); + + it("both env vars set → .hivemind routing is fully ignored", () => { + write(dir("proj"), ".hivemind", { orgId: "acme", workspaceId: "client-work" }); + const pinned = { ...base(), orgId: "env-org", orgName: "env-org", workspaceId: "env-ws" }; + const res = resolveDirConfig(pinned, dir("proj"), { + HIVEMIND_ORG_ID: "env-org", + HIVEMIND_WORKSPACE_ID: "env-ws", + }); + expect(res.config.orgId).toBe("env-org"); + expect(res.config.workspaceId).toBe("env-ws"); + }); + + it("collect:false is a fail-safe opt-out — env does not force capture on", () => { + write(dir("proj"), ".hivemind", { collect: false }); + const res = resolveDirConfig(base(), dir("proj"), { HIVEMIND_ORG_ID: "env-org" }); + expect(res.collect).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index b23f15c4..d73e1742 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -75,6 +75,21 @@ export default defineConfig({ // on the new code without having to first bring the whole // (~500-file) codebase up to 80%. thresholds: { + // PR #302 — feat: per-directory .hivemind config. The resolver is + // fully unit-tested; the hook gate is exercised via the capture/ + // session-start wiring tests. Lock both at 90. + "src/dir-config.ts": { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, + "src/hooks/shared/dir-gate.ts": { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, // PR #60 — fix/grep-dual-table-and-normalize. // Raised to 90 to surface the red path in the PR coverage comment // for metrics that sit between 80 and 90 (e.g. grep-core branches