diff --git a/README.md b/README.md index 662be0a5..fd7ec1cd 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ 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). +Turn Hivemind off 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: @@ -362,27 +362,31 @@ Drop a `.hivemind` JSON file at the root of the tree you want to configure: |---------------|-------------------------------------------------------------------------------| | `orgId` | Route this tree to this org — captured traces **and** memory reads. | | `workspaceId` | Route to this workspace. | -| `collect` | `false` → **never** capture traces from this tree. Reads still route. | +| `collect` | `false` → Hivemind is **completely inactive** in this tree: no capture, context, memory reads or notifications. | Any field may be omitted; omitted fields fall back to your global identity. -`orgId` / `workspaceId` are **identity** (they apply to reads and writes alike); `collect` is a **capture switch** (writes only). The two are independent, which is what makes the read-only recipe below work. +`orgId` / `workspaceId` are **identity** (they apply to reads and writes alike); `collect: false` switches Hivemind **off** for the tree. -**Three common recipes:** +**Common recipes:** ```jsonc // route this repo to a client org/workspace — reads and writes both land there { "orgId": "acme-corp", "workspaceId": "client-work" } -// never collect traces from this folder (e.g. a personal or sensitive repo) +// Hivemind fully off in this folder (e.g. a personal or sensitive repo) { "collect": false } - -// read a shared workspace's memory, but never write to it -{ "workspaceId": "client-work", "collect": false } ``` Routing never carries a token — auth stays in `~/.deeplake/credentials.json`, so a `.hivemind` only ever takes effect against orgs your existing login already authorizes. An `HIVEMIND_ORG_ID` / `HIVEMIND_WORKSPACE_ID` set in your environment **wins over** a `.hivemind` for that field; `hivemind whoami` discloses which one is in effect. +**Run Hivemind only in chosen repos.** Turn it off for a whole tree and back on per repo (nearest file wins): + +```bash +echo '{ "collect": false }' > ~/.hivemind.local # or at the root of your source tree +echo '{ "collect": true }' > ~/src/my-repo/.hivemind.local # repeat per repo +``` + ### Committed vs local Two filenames are recognized, mirroring the `.env` / `.env.local` convention every dev already knows: @@ -426,7 +430,7 @@ Because a `.hivemind` travels with a repo, cloning someone's repo could in princ |------------------------------|------------------------------------------------------------| | 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. | +| Dir with `collect: false` | Hivemind inactive, 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. diff --git a/src/dir-config.ts b/src/dir-config.ts index b86a36cc..e262428c 100644 --- a/src/dir-config.ts +++ b/src/dir-config.ts @@ -152,6 +152,16 @@ export function resolveDirConfig( return { config, collect: found.raw.collect !== false, found }; } +/** + * False when the nearest `.hivemind` / `.hivemind.local` says `"collect": false`. + * Session-start, notification and pre-tool-use hooks return early on false, so + * Hivemind is completely silent in that tree (not just capture-off). A nearer + * `{ "collect": true }` re-enables a repo below an opted-out parent. + */ +export function isHivemindEnabled(cwd: string): boolean { + return findDirConfig(cwd)?.raw.collect !== false; +} + /** * THE single entry point for a workspace-scoped Config. * diff --git a/src/hooks/codex/pre-tool-use.ts b/src/hooks/codex/pre-tool-use.ts index 6cdaf61f..a2e5e0b6 100644 --- a/src/hooks/codex/pre-tool-use.ts +++ b/src/hooks/codex/pre-tool-use.ts @@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { readStdin } from "../../utils/stdin.js"; import { loadConfig } from "../../config.js"; -import { resolveDirConfig } from "../../dir-config.js"; +import { resolveDirConfig, isHivemindEnabled } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { sqlLike } from "../../utils/sql.js"; import { parseBashGrep, handleGrepDirect } from "../grep-direct.js"; @@ -461,6 +461,7 @@ export async function processCodexPreToolUse( /* c8 ignore start */ async function main(): Promise { const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive // SkillOpt: codex USES an org skill by shelling a read of its SKILL.md — arm the judgment // window on that command. Guarded at the call site too (armSkillOptOnSkillUse is already // internally swallowed): a throw here must NOT short-circuit the memory-path gate below, whose diff --git a/src/hooks/codex/session-start-setup.ts b/src/hooks/codex/session-start-setup.ts index 72fb6a0a..f100b8f1 100644 --- a/src/hooks/codex/session-start-setup.ts +++ b/src/hooks/codex/session-start-setup.ts @@ -11,7 +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 { resolveDirConfig, isHivemindEnabled } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { readStdin } from "../../utils/stdin.js"; import { createPlaceholderSummary } from "../shared/placeholder-summary.js"; @@ -49,6 +49,7 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive // Provision the code-graph tree-sitter parsers into the shared embed-deps // dir so the graph-on-stop hook can auto-build the graph. Spawned as a @@ -87,16 +88,14 @@ async function main(): Promise { if (base) { const dirRes = resolveDirConfig(base, input.cwd ?? process.cwd()); const config = dirRes.config; - if (captureEnabled && dirRes.collect) { + if (captureEnabled) { 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 skipped — HIVEMIND_CAPTURE=false"); } } } catch (e: any) { diff --git a/src/hooks/codex/session-start.ts b/src/hooks/codex/session-start.ts index 5f518b7b..c02a50b6 100644 --- a/src/hooks/codex/session-start.ts +++ b/src/hooks/codex/session-start.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { loadCredentials, healDriftedOrgToken, resolveWorkspaceOverride } from "../../commands/auth.js"; import { readStdin } from "../../utils/stdin.js"; +import { isHivemindEnabled } from "../../dir-config.js"; import { countLocalManifestEntries } from "../../skillify/local-manifest.js"; import { maybeAutoMineLocal } from "../../skillify/spawn-mine-local-worker.js"; import { log as _log } from "../../utils/debug.js"; @@ -79,6 +80,7 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive let creds = loadCredentials(); let workspaceWarning = ""; diff --git a/src/hooks/cursor/pre-tool-use.ts b/src/hooks/cursor/pre-tool-use.ts index 6950aa40..e2705a9f 100644 --- a/src/hooks/cursor/pre-tool-use.ts +++ b/src/hooks/cursor/pre-tool-use.ts @@ -28,7 +28,7 @@ import { readStdin } from "../../utils/stdin.js"; import { deriveProjectKey } from "../../utils/repo-identity.js"; -import { loadRoutedConfig } from "../../dir-config.js"; +import { loadRoutedConfig, isHivemindEnabled } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { log as _log } from "../../utils/debug.js"; import { parseBashGrep, handleGrepDirect } from "../grep-direct.js"; @@ -54,6 +54,7 @@ interface CursorPreToolUseInput { async function main(): Promise { const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? input.workspace_roots?.[0] ?? process.cwd())) return; // .hivemind "collect": false → fully inactive if (input.tool_name !== "Shell") return; // only intercept Shell, not Read/Write/MCP const command = (input.tool_input as CursorShellToolInput | undefined)?.command; diff --git a/src/hooks/cursor/session-end.ts b/src/hooks/cursor/session-end.ts index 5a665e8e..2b917474 100644 --- a/src/hooks/cursor/session-end.ts +++ b/src/hooks/cursor/session-end.ts @@ -22,6 +22,7 @@ const log = (msg: string) => _log("cursor-session-end", msg); interface CursorSessionEndInput { conversation_id?: string; + workspace_roots?: string[]; session_id?: string; reason?: string; duration_ms?: number; @@ -36,7 +37,9 @@ async function main(): Promise { if (!sessionId) return; const base = loadConfig(); if (!base) { wikiLog(`SessionEnd: no config, skipping summary`); return; } - const dirRes = resolveDirConfig(base, process.cwd()); + // Cursor runs user hooks from ~/.cursor, so process.cwd() is not the project. + const cwd = input.workspace_roots?.[0] ?? process.cwd(); + const dirRes = resolveDirConfig(base, cwd); if (!dirRes.collect) { wikiLog(`SessionEnd: capture disabled for this directory (${dirRes.found?.path})`); return; } const config = dirRes.config; @@ -46,7 +49,7 @@ async function main(): Promise { try { forceSessionEndTrigger({ config, - cwd: process.cwd(), + cwd, bundleDir: bundleDirFromImportMeta(import.meta.url), agent: "cursor", sessionId, @@ -66,7 +69,7 @@ async function main(): Promise { spawnCursorWikiWorker({ config, sessionId, - cwd: process.cwd(), + cwd, bundleDir: bundleDirFromImportMeta(import.meta.url), reason: "SessionEnd", }); diff --git a/src/hooks/cursor/session-start.ts b/src/hooks/cursor/session-start.ts index c7ea6535..42096581 100644 --- a/src/hooks/cursor/session-start.ts +++ b/src/hooks/cursor/session-start.ts @@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { loadCredentials, healDriftedOrgToken, resolveWorkspaceOverride } from "../../commands/auth.js"; import { loadConfig } from "../../config.js"; -import { resolveDirConfig } from "../../dir-config.js"; +import { resolveDirConfig, isHivemindEnabled } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { renderContextBlock } from "../shared/context-renderer.js"; import { createPlaceholderSummary } from "../shared/placeholder-summary.js"; @@ -124,6 +124,7 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + if (!isHivemindEnabled(resolveCwd(input))) return; // .hivemind "collect": false → fully inactive const sessionId = resolveSessionId(input); const cwd = resolveCwd(input); @@ -164,7 +165,7 @@ async function main(): Promise { // 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); + const collectHere = captureEnabled; let rulesBlock = ""; if (creds?.token) { try { @@ -179,9 +180,7 @@ async function main(): Promise { await createPlaceholder(api, table, sessionId, cwd, config.userName, config.orgName, config.workspaceId, pluginVersion); log("placeholder created"); } else { - log(dirRes && !dirRes.collect - ? `placeholder + schema ensure skipped (.hivemind collect:false ${dirRes.found?.path})` - : "placeholder + schema ensure skipped (HIVEMIND_CAPTURE=false)"); + log("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 @@ -231,13 +230,11 @@ async function main(): Promise { // Disclose the EFFECTIVE identity (after any `.hivemind` overlay). const effConfig = dirRes?.config ?? baseConfig; - const routed = !!(dirRes?.found && dirRes.collect && baseConfig && + const routed = !!(dirRes?.found && 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 identityLine = `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routed ? ` · routed by ${dirRes?.found?.path}` : ""}`; const baseContext = creds?.token ? `${context}\n${identityLine}${workspaceWarning}${versionNotice}` : `${context}\nNot logged in to Deeplake. Run: hivemind login${localMinedNote}${versionNotice}`; diff --git a/src/hooks/hermes/pre-tool-use.ts b/src/hooks/hermes/pre-tool-use.ts index 3e4b5f08..f439b4a3 100644 --- a/src/hooks/hermes/pre-tool-use.ts +++ b/src/hooks/hermes/pre-tool-use.ts @@ -21,7 +21,7 @@ */ import { readStdin } from "../../utils/stdin.js"; -import { loadRoutedConfig } from "../../dir-config.js"; +import { loadRoutedConfig, isHivemindEnabled } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { log as _log } from "../../utils/debug.js"; import { parseBashGrep, handleGrepDirect } from "../grep-direct.js"; @@ -41,6 +41,7 @@ interface HermesPreToolUseInput { async function main(): Promise { const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive // SkillOpt: hermes USES an org skill by shelling a read of its SKILL.md (the path is in the // terminal command). Arm the judgment window on it. Swallowed; never affects the decision below. armSkillOptOnSkillUse(input.session_id ?? "", input.tool_name ?? "", input.tool_input); diff --git a/src/hooks/hermes/session-start.ts b/src/hooks/hermes/session-start.ts index 8efef012..12bec165 100644 --- a/src/hooks/hermes/session-start.ts +++ b/src/hooks/hermes/session-start.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { loadCredentials, healDriftedOrgToken, resolveWorkspaceOverride } from "../../commands/auth.js"; import { loadConfig } from "../../config.js"; -import { resolveDirConfig } from "../../dir-config.js"; +import { resolveDirConfig, isHivemindEnabled } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { renderContextBlock } from "../shared/context-renderer.js"; import { createPlaceholderSummary } from "../shared/placeholder-summary.js"; @@ -90,8 +90,9 @@ async function createPlaceholder( async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); - const sessionId = input.session_id ?? `hermes-${Date.now()}`; const cwd = input.cwd ?? process.cwd(); + if (!isHivemindEnabled(cwd)) return; // .hivemind "collect": false → fully inactive + const sessionId = input.session_id ?? `hermes-${Date.now()}`; let creds = loadCredentials(); let workspaceWarning = ""; @@ -116,7 +117,7 @@ async function main(): Promise { // the heal + override steps so loadConfig() sees the repaired credentials. const baseConfig = loadConfig(); const dirRes = baseConfig ? resolveDirConfig(baseConfig, cwd) : null; - const collectHere = captureEnabled && (dirRes?.collect ?? true); + const collectHere = captureEnabled; // Centralized autoupdate fires BEFORE the DB ensure-table calls — those // can stall for tens of seconds against a slow/unreachable backend, and @@ -145,9 +146,7 @@ async function main(): Promise { await createPlaceholder(api, config.tableName, sessionId, cwd, config.userName, config.orgName, config.workspaceId, pluginVersion); log("placeholder created"); } else { - log(dirRes && !dirRes.collect - ? `placeholder + schema ensure skipped (.hivemind collect:false ${dirRes.found?.path})` - : "placeholder + schema ensure skipped (HIVEMIND_CAPTURE=false)"); + log("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. @@ -195,13 +194,11 @@ async function main(): Promise { // Disclose the EFFECTIVE identity (after any `.hivemind` overlay). const effConfig = dirRes?.config ?? baseConfig; - const routed = !!(dirRes?.found && dirRes.collect && baseConfig && + const routed = !!(dirRes?.found && 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 identityLine = `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routed ? ` · routed by ${dirRes?.found?.path}` : ""}`; const baseContext = creds?.token ? `${context}\n${identityLine}${workspaceWarning}${versionNotice}` : `${context}\nNot logged in to Deeplake. Run: hivemind login${localMinedNote}${versionNotice}`; diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 363affed..5820a889 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -7,7 +7,7 @@ import { join, dirname, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { readStdin } from "../utils/stdin.js"; import { loadConfig } from "../config.js"; -import { resolveDirConfig } from "../dir-config.js"; +import { resolveDirConfig, isHivemindEnabled } from "../dir-config.js"; import { armSkillOptOnSkillUse } from "./shared/skillopt-hook.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { sqlLike } from "../utils/sql.js"; @@ -645,6 +645,7 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT /* c8 ignore start */ async function main(): Promise { const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive // Self-heal the owner record from a SYNCHRONOUS hook. SessionStart records it // for new sessions, but a session already open when this shipped only gets a // record via the async capture hook — which can be detached and unable to diff --git a/src/hooks/session-notifications.ts b/src/hooks/session-notifications.ts index 4d281899..c24651fc 100644 --- a/src/hooks/session-notifications.ts +++ b/src/hooks/session-notifications.ts @@ -14,6 +14,7 @@ import { loadCredentials } from "../commands/auth.js"; import { readStdin } from "../utils/stdin.js"; +import { isHivemindEnabled } from "../dir-config.js"; import { drainSessionStart, registerRule } from "../notifications/index.js"; import { bumpSessionCount } from "../notifications/state.js"; import { referralInviteRule } from "../notifications/rules/referral-invite.js"; @@ -50,6 +51,7 @@ async function main(): Promise { // session — two parallel hook fires for the same session share the // same id and dedupe to one emission via the atomic claim file. const input = await readStdin().catch(() => ({} as SessionStartInput)); + if (!isHivemindEnabled(input?.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive // Trim + non-empty check: an empty or whitespace-only session_id would // collapse the dedupKey across unrelated sessions. pickPrimaryBanner // returns null when sessionId is undefined; route there instead of diff --git a/src/hooks/session-start-setup.ts b/src/hooks/session-start-setup.ts index 192b1081..76a112c0 100644 --- a/src/hooks/session-start-setup.ts +++ b/src/hooks/session-start-setup.ts @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { loadCredentials, saveCredentials } from "../commands/auth.js"; -import { loadRoutedConfig } from "../dir-config.js"; +import { loadRoutedConfig, isHivemindEnabled } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { readStdin } from "../utils/stdin.js"; import { log as _log } from "../utils/debug.js"; @@ -33,6 +33,7 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive // Provision the code-graph tree-sitter parsers into the shared embed-deps // dir so the graph-on-stop hook can auto-build the graph. Spawned as a diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 72ad5852..7b9afc1a 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -14,7 +14,7 @@ import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { loadCredentials, saveCredentials, healDriftedOrgToken, resolveWorkspaceOverride } from "../commands/auth.js"; import { loadConfig } from "../config.js"; -import { resolveDirConfig } from "../dir-config.js"; +import { resolveDirConfig, isHivemindEnabled } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { readStdin } from "../utils/stdin.js"; import { log as _log } from "../utils/debug.js"; @@ -124,6 +124,7 @@ async function main(): Promise { log(`hook entered (pid=${process.pid})`); const input = await readStdin(); + if (!isHivemindEnabled(input.cwd ?? process.cwd())) return; // .hivemind "collect": false → fully inactive // A fresh start or --resume of this session re-activates it: drop any stale // ended marker and record the owning `claude` process so other sessions can @@ -208,7 +209,7 @@ async function main(): Promise { const sessionCwd = input.cwd ?? process.cwd(); const baseConfig = loadConfig(); const dirRes = baseConfig ? resolveDirConfig(baseConfig, sessionCwd) : null; - const collectHere = captureEnabled && (dirRes?.collect ?? true); + const collectHere = captureEnabled; // Auto-pull skills from all org users into ~/.claude/skills/ on every // SessionStart. File writes inside runPull are idempotent (skipped @@ -235,11 +236,9 @@ async function main(): Promise { await createPlaceholder(api, table, input.session_id, sessionCwd, config.userName, config.orgName, config.workspaceId, pluginVersion); log("placeholder created"); } else { - const reason = dirRes && !dirRes.collect - ? `.hivemind collect:false (${dirRes.found?.path})` - : process.env.HIVEMIND_CAPTURE === "false" - ? "HIVEMIND_CAPTURE=false" - : "HIVEMIND_CAPTURE_ONLY_CLI gate"; + const reason = 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. @@ -338,9 +337,6 @@ async function main(): Promise { // 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; - // NOT gated on `dirRes.collect`: the identity overlay now applies to reads - // whether or not capture is on, so a `collect:false` directory can still be - // routed — and must say so. const routed = !!(dirRes?.found && baseConfig && (dirRes.config.orgId !== baseConfig.orgId || dirRes.config.workspaceId !== baseConfig.workspaceId)); const effOrg = effConfig ? (effConfig.orgName ?? effConfig.orgId) : (creds?.orgName ?? creds?.orgId); @@ -348,9 +344,7 @@ async function main(): Promise { // `routed` covers reads AND capture — both resolve through the same overlay — // so the disclosure must never imply one moved without the other. const routedNote = routed ? ` · routed by ${dirRes?.found?.path}` : ""; - const identityLine = dirRes && !dirRes.collect - ? `Deeplake capture is disabled for this directory (${dirRes.found?.path}); memory search uses org: ${effOrg} (workspace: ${effWs})${routedNote}` - : `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routedNote}`; + const identityLine = `Logged in to Deeplake as org: ${effOrg} (workspace: ${effWs})${routedNote}`; const baseContext = creds?.token ? `${resolvedContext}\n\n${identityLine}${workspaceWarning}${updateNotice}` : `${resolvedContext}\n\nNot logged in to Deeplake; memory search is unavailable this session.${localMinedNote}${updateNotice}`; diff --git a/tests/claude-code/session-start-hook.test.ts b/tests/claude-code/session-start-hook.test.ts index 153fcef7..63a6e3d0 100644 --- a/tests/claude-code/session-start-hook.test.ts +++ b/tests/claude-code/session-start-hook.test.ts @@ -247,12 +247,11 @@ describe("session-start hook — guards", () => { expect(ensureTableMock).toHaveBeenCalled(); }); - it("collect:false skips the placeholder/table setup and says capture is disabled", async () => { + it("collect:false makes the hook fully inactive — no context, no API calls", 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(out).toBeNull(); + expect(queryMock).not.toHaveBeenCalled(); expect(ensureTableMock).not.toHaveBeenCalled(); expect(ensureSessionsTableMock).not.toHaveBeenCalled(); }); diff --git a/tests/claude-code/session-start-setup-hook.test.ts b/tests/claude-code/session-start-setup-hook.test.ts index 9ec70e29..938a0940 100644 --- a/tests/claude-code/session-start-setup-hook.test.ts +++ b/tests/claude-code/session-start-setup-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"; /** * Source-level tests for src/hooks/session-start-setup.ts. This hook @@ -323,3 +326,24 @@ describe("session-start-setup hook — fatal catch", () => { // isNewer comparison — are tested at the layer they belong to: // `src/cli/update.ts` and the autoUpdate helper itself, not in the // per-agent setup hook.) + +describe("session-start-setup hook — .hivemind collect:false", () => { + let hmDir = ""; + afterEach(() => { if (hmDir) rmSync(hmDir, { recursive: true, force: true }); hmDir = ""; }); + + it("does nothing in a collect:false tree — no worker spawn, no creds, no table setup", async () => { + hmDir = mkdtempSync(join(tmpdir(), "setup-hivemind-")); + writeFileSync(join(hmDir, ".hivemind"), JSON.stringify({ collect: false })); + stdinMock.mockResolvedValue({ session_id: "sid-1", cwd: hmDir }); + await runHook(); + expect(spawnDetachedMock).not.toHaveBeenCalled(); + expect(loadCredsMock).not.toHaveBeenCalled(); + expect(ensureTableMock).not.toHaveBeenCalled(); + }); + + it("falls back to process.cwd() when stdin has no cwd", async () => { + stdinMock.mockResolvedValue({ session_id: "sid-1" }); + await runHook(); + expect(spawnDetachedMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/cursor/cursor-session-start-hook.test.ts b/tests/cursor/cursor-session-start-hook.test.ts index 8332d576..b5328a53 100644 --- a/tests/cursor/cursor-session-start-hook.test.ts +++ b/tests/cursor/cursor-session-start-hook.test.ts @@ -292,10 +292,11 @@ describe("cursor session-start hook — per-directory .hivemind", () => { expect(ensureTableMock).toHaveBeenCalled(); }); - it("collect:false skips table setup and says capture is disabled", async () => { + it("collect:false makes the hook fully inactive — no context, no API calls", async () => { withHivemind({ collect: false }); await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); - expect(banner()).toContain("capture is disabled for this directory"); + expect(consoleLogMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); expect(ensureTableMock).not.toHaveBeenCalled(); expect(ensureSessionsTableMock).not.toHaveBeenCalled(); }); diff --git a/tests/hermes/hermes-session-start-hook.test.ts b/tests/hermes/hermes-session-start-hook.test.ts index e6b5accc..f50c0a47 100644 --- a/tests/hermes/hermes-session-start-hook.test.ts +++ b/tests/hermes/hermes-session-start-hook.test.ts @@ -187,6 +187,22 @@ describe("hermes session-start hook — context payload", () => { expect(payload.context).toContain("org: o-99"); }); + it("identity line falls back to the stored creds when loadConfig returns null", async () => { + loadConfigMock.mockReturnValue(null); + loadCredentialsMock.mockReturnValue({ token: "t", orgName: "creds-org", workspaceId: "creds-ws" }); + await runHook(); + const payload = JSON.parse(consoleLogMock.mock.calls[0][0] as string); + expect(payload.context).toContain("Logged in to Deeplake as org: creds-org (workspace: creds-ws)"); + }); + + it("identity line uses orgId and the 'default' workspace when creds omit them", async () => { + loadConfigMock.mockReturnValue(null); + loadCredentialsMock.mockReturnValue({ token: "t", orgId: "org-id-1" }); + await runHook(); + const payload = JSON.parse(consoleLogMock.mock.calls[0][0] as string); + expect(payload.context).toContain("Logged in to Deeplake as org: org-id-1 (workspace: default)"); + }); + it("omits the version notice when getInstalledVersion returns null", async () => { getInstalledVersionMock.mockReturnValue(null); await runHook(); @@ -289,10 +305,11 @@ describe("hermes session-start hook — per-directory .hivemind", () => { expect(ensureTableMock).toHaveBeenCalled(); }); - it("collect:false skips table setup and says capture is disabled", async () => { + it("collect:false makes the hook fully inactive — no context, no API calls", async () => { withHivemind({ collect: false }); await runHook({ HIVEMIND_ORG_ID: undefined, HIVEMIND_WORKSPACE_ID: undefined }); - expect(banner()).toContain("capture is disabled for this directory"); + expect(consoleLogMock).not.toHaveBeenCalled(); + expect(queryMock).not.toHaveBeenCalled(); expect(ensureTableMock).not.toHaveBeenCalled(); expect(ensureSessionsTableMock).not.toHaveBeenCalled(); }); diff --git a/tests/shared/dir-config.test.ts b/tests/shared/dir-config.test.ts index 22a1267c..869d6ed2 100644 --- a/tests/shared/dir-config.test.ts +++ b/tests/shared/dir-config.test.ts @@ -7,6 +7,7 @@ import { findDirConfig, parseDirConfig, resolveDirConfig, + isHivemindEnabled, } from "../../src/dir-config.js"; let root: string; @@ -213,3 +214,22 @@ describe("resolveDirConfig — env precedence (env > .hivemind)", () => { expect(res.collect).toBe(false); }); }); + +describe("isHivemindEnabled (collect:false = fully inactive)", () => { + it("collect:false at a source root turns Hivemind off for every repo below it", () => { + write(dir("src"), ".hivemind", { collect: false }); + expect(isHivemindEnabled(dir("src", "unrelated", "pkg"))).toBe(false); + }); + + it("a nearer collect:true opts a repo back in", () => { + write(dir("src"), ".hivemind", { collect: false }); + write(dir("src", "deeplake"), ".hivemind.local", { collect: true }); + expect(isHivemindEnabled(dir("src", "deeplake", "pkg"))).toBe(true); + }); + + it("enabled with no file or a routing-only file (unchanged behavior)", () => { + expect(isHivemindEnabled(dir("plain"))).toBe(true); + write(dir("team"), ".hivemind", { workspaceId: "w" }); + expect(isHivemindEnabled(dir("team"))).toBe(true); + }); +});