diff --git a/README.md b/README.md index 662be0a5..843b4602 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,16 @@ 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). +Run Hivemind **only** in the repos you choose, and keep it completely inactive everywhere else (no context injection, recall, notifications, network calls or capture): + +```bash +hivemind activation opt-in # once, machine-wide +cd ~/src/my-repo && hivemind activation enable # per repo you want it in +hivemind activation # is it active here, and why? +``` + +See [Opt-in only (`activation`)](#opt-in-only-activation). + Enable debug logging: ```bash @@ -333,6 +343,7 @@ This plugin captures session activity and stores it in your Deeplake workspace: | `HIVEMIND_SESSIONS_TABLE` | `sessions` | SQL table for per-event session capture | | `HIVEMIND_MEMORY_PATH` | `~/.deeplake/memory` | Path that triggers interception | | `HIVEMIND_CAPTURE` | `true` | Set to `false` to disable capture | +| `HIVEMIND_ACTIVATION` | _(config file, else `always`)_ | `opt-in` → Hivemind inactive except in trees with `"enabled": true`; `always` → active everywhere. Overrides `activation.mode` in `~/.deeplake/config.json` (set with `hivemind activation opt-in\|always`). | | `HIVEMIND_CAPTURE_ONLY_CLI` | _(none)_ | Set to `true` to capture only interactive CLI sessions. Sessions spawned by the Claude Agent SDK (Python/TypeScript) are skipped; their `CLAUDE_CODE_ENTRYPOINT` is `sdk-py` / `sdk-ts`, so they fail the substring check for `cli`. | | `HIVEMIND_SKILLIFY_EVERY_N_TURNS` | `20` | Assistant turns between auto skill-mining attempts. Lower = more frequent mining (cheaper sessions, noisier output); higher = fewer attempts on longer histories. | | `HIVEMIND_SUMMARY_EVERY_N_MSGS` | `50` | Captured events between periodic session summaries. The first summary of a session runs at 10 events regardless. Raise it to cut background summary runs. | @@ -363,6 +374,7 @@ 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. | +| `enabled` | `false` → Hivemind is **completely inactive** in this tree (no context, recall, network, capture). `true` → opts the tree in under [opt-in mode](#opt-in-only-activation). | Any field may be omitted; omitted fields fall back to your global identity. @@ -383,6 +395,37 @@ Any field may be omitted; omitted fields fall back to your global identity. 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. +> **`collect: false` is not "off".** It stops *writes* only. Sessions in that tree still log in, inject the Hivemind context, read team rules and memory, and fetch notifications. To make Hivemind fully inactive in a tree use `{ "enabled": false }`, or switch to [opt-in mode](#opt-in-only-activation). + +### Opt-in only (`activation`) + +By default Hivemind runs in every directory. To flip that so it is **completely inactive** except in trees you have explicitly opted in: + +```bash +hivemind activation opt-in # writes { "activation": { "mode": "opt-in" } } to ~/.deeplake/config.json +``` + +Then, in each repo where you want Hivemind: + +```bash +cd ~/src/deeplake +hivemind activation enable # writes { "enabled": true } to ./.hivemind.local (personal, gitignore it) +hivemind activation enable --shared # or to ./.hivemind (commit it; opts in the whole team) +``` + +| Global mode | Nearest `.hivemind` / `.hivemind.local` | Hivemind in that tree | +|-------------|------------------------------------------|-----------------------| +| `always` (default) | none, or no `enabled` field | active | +| `always` | `"enabled": false` | **inactive** | +| `opt-in` | `"enabled": true` | active | +| `opt-in` | anything else (none, routing only, `collect` only) | **inactive** | + +"Inactive" means the agent hooks exit immediately: no context is injected, no memory interception, no notifications, no autoupdate check, no skill auto-pull, no graph workers, no capture, and **no network calls**. `enabled` follows the same nearest-file-wins rule as every other field, so a repo's `.hivemind.local` with `{ "enabled": true }` re-enables it under a parent that says `false`. `hivemind activation enable` keeps the other fields of an existing file, and seeds a new `.hivemind.local` from a sibling `.hivemind` so the repo's routing isn't lost. + +`hivemind activation` (no argument) prints the mode, the file that decided, and whether Hivemind is active in the current directory; `hivemind whoami` shows the same verdict. `HIVEMIND_ACTIVATION=opt-in|always` overrides the config file for a single process. The gate covers the Claude Code, Codex, Cursor, Hermes and pi hooks. MCP servers registered for Claude Desktop / Cowork have no working directory and aren't affected; uninstall them if you don't want them. + +Changes take effect on the next agent session (restart Cursor or open a new chat). + ### Committed vs local Two filenames are recognized, mirroring the `.env` / `.env.local` convention every dev already knows: diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a24d1e41..6f60a9a6 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -640,6 +640,9 @@ const openclawGraphWorkerDefine = { "process.env.HIVEMIND_GRAPH_TICK_INTERVAL_MS": "globalThis.__hivemind_tuning__.HIVEMIND_GRAPH_TICK_INTERVAL_MS", "process.env.HIVEMIND_GRAPH_PULL": "globalThis.__hivemind_tuning__.HIVEMIND_GRAPH_PULL", "process.env.HIVEMIND_GRAPH_PULL_TIMEOUT_MS": "globalThis.__hivemind_tuning__.HIVEMIND_GRAPH_PULL_TIMEOUT_MS", + // Activation gate (src/activation.ts), pulled in via graph-on-stop and + // dir-config. Same env-harvesting rationale as the entries below. + "process.env.HIVEMIND_ACTIVATION": "globalThis.__hivemind_tuning__.HIVEMIND_ACTIVATION", "process.env.HIVEMIND_DOCS_AUTO_FILE": "undefined", "process.env.HIVEMIND_DOCS_TABLE": "globalThis.__hivemind_tuning__.HIVEMIND_DOCS_TABLE", // Transitively imported via DeeplakeApi -> index-marker-store.ts. Without diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index 20594980..dcea5e06 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -114,7 +114,7 @@ function loadCreds(): Creds | null { // 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; } +interface PiDirConfig { orgId?: string; orgName?: string; workspaceId?: string; collect?: boolean; enabled?: boolean; } function findHivemindDir(startDir: string): PiDirConfig | null { let dir = startDir || process.cwd(); @@ -128,6 +128,7 @@ function findHivemindDir(startDir: string): PiDirConfig | null { 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; + if (typeof raw.enabled === "boolean") out.enabled = raw.enabled; return out; } } catch { /* absent / unparseable — keep walking up */ } @@ -138,6 +139,40 @@ function findHivemindDir(startDir: string): PiDirConfig | null { } } +// Activation gate — self-contained mirror of src/activation.ts. Global mode +// from HIVEMIND_ACTIVATION, else ~/.deeplake/config.json `activation.mode` +// ("always" default | "opt-in"). A nearest `.hivemind` with `enabled: false` +// turns Hivemind fully off; in opt-in mode only `enabled: true` turns it on. +function piActivationMode(): "always" | "opt-in" { + const norm = (v: unknown): "always" | "opt-in" | null => { + if (typeof v !== "string") return null; + const s = v.trim().toLowerCase(); + if (s === "opt-in" || s === "optin" || s === "opt_in") return "opt-in"; + if (s === "always" || s === "on" || s === "default") return "always"; + return null; + }; + const fromEnv = norm(process.env.HIVEMIND_ACTIVATION); + if (fromEnv) return fromEnv; + try { + const path = process.env.HIVEMIND_CONFIG_PATH ?? join(homedir(), ".deeplake", "config.json"); + const cfg = JSON.parse(readFileSync(path, "utf-8")); + return norm(cfg?.activation?.mode) ?? "always"; + } catch { + return "always"; + } +} + +function piIsActive(cwd: string): boolean { + const mode = piActivationMode(); + let dir: PiDirConfig | null = null; + try { dir = findHivemindDir(cwd || process.cwd()); } catch { return mode !== "opt-in"; } + if (dir?.enabled === false) return false; + if (mode === "opt-in") return dir?.enabled === true; + return true; +} + +const INACTIVE_TEXT = "Hivemind is not active in this directory (opt-in mode or `.hivemind` \"enabled\": false)."; + /** 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. */ @@ -1409,6 +1444,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { required: ["query"], }, async execute(_toolCallId: string, params: { query: string; limit?: number }) { + if (!piIsActive(process.cwd())) return textResult(INACTIVE_TEXT); const creds = loadCreds(); if (!creds) return textResult("Hivemind: not authenticated. Run `hivemind login` in a terminal."); try { @@ -1428,6 +1464,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { required: ["path"], }, async execute(_toolCallId: string, params: { path: string }) { + if (!piIsActive(process.cwd())) return textResult(INACTIVE_TEXT); const creds = loadCreds(); if (!creds) return textResult("Hivemind: not authenticated."); const path = params.path; @@ -1456,6 +1493,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { }, }, async execute(_toolCallId: string, params: { prefix?: string; limit?: number }) { + if (!piIsActive(process.cwd())) return textResult(INACTIVE_TEXT); const creds = loadCreds(); if (!creds) return textResult("Hivemind: not authenticated."); const where = params.prefix @@ -1486,6 +1524,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { // themselves don't carry them. pi.on("session_start", async (_event: any, ctx: any) => { + if (!piIsActive(ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd())) { logHm(`session_start: hivemind inactive for this directory, skipping`); return; } logHm(`session_start: fired (capture=${captureEnabled}, embed=${process.env.HIVEMIND_EMBEDDINGS !== "false"}, table=${SESSIONS_TABLE})`); // Tell the user about anything that needs their attention — most @@ -1665,6 +1704,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { }); pi.on("input", async (event: any, ctx: any) => { + if (!piIsActive(ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd())) { logHm(`input: hivemind inactive for this directory, skipping`); return; } logHm(`input: fired source=${event?.source ?? "?"}`); if (!captureEnabled) { logHm(`input: capture disabled, skipping`); return; } if (event.source === "extension") { logHm(`input: extension-injected, skipping`); return; } @@ -1694,6 +1734,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { }); pi.on("tool_result", async (event: any, ctx: any) => { + if (!piIsActive(ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd())) { logHm(`tool_result: hivemind inactive for this directory, skipping`); return; } logHm(`tool_result: fired tool=${event?.toolName ?? "?"} isError=${event?.isError === true}`); if (!captureEnabled) { logHm(`tool_result: capture disabled, skipping`); return; } let creds = loadCreds(); @@ -1731,6 +1772,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { }); pi.on("message_end", async (event: any, ctx: any) => { + if (!piIsActive(ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd())) { logHm(`message_end: hivemind inactive for this directory, skipping`); return; } logHm(`message_end: fired role=${event?.message?.role ?? "?"}`); if (!captureEnabled) { logHm(`message_end: capture disabled, skipping`); return; } let creds = loadCreds(); @@ -1770,6 +1812,7 @@ export default function hivemindExtension(pi: ExtensionAPI): void { }); pi.on("session_shutdown", async (_event: any, ctx: any) => { + if (!piIsActive(ctx?.cwd ?? ctx?.sessionManager?.getCwd?.() ?? process.cwd())) { logHm(`session_shutdown: hivemind inactive for this directory, skipping`); return; } logHm(`session_shutdown: fired`); if (process.env.HIVEMIND_CAPTURE === "false") return; let creds = loadCreds(); diff --git a/src/activation.ts b/src/activation.ts new file mode 100644 index 00000000..3a44b672 --- /dev/null +++ b/src/activation.ts @@ -0,0 +1,118 @@ +/** + * Activation gate — decides whether Hivemind does ANYTHING for a session + * rooted at `cwd`: context injection, memory recall, notifications, + * autoupdate, skill auto-pull, graph workers, and capture. + * + * This is deliberately a separate, stronger switch than `.hivemind`'s + * `collect` field, which only turns off capture (writes) while reads, rules + * and the session-start context keep working. + * + * Two inputs: + * + * 1. The global mode, from `~/.deeplake/config.json` → + * `{ "activation": { "mode": "opt-in" | "always" } }` + * (overridable per-process with `HIVEMIND_ACTIVATION=opt-in|always`). + * Default `always` — today's behavior, so nothing changes for existing + * installs. + * + * 2. The nearest `.hivemind.local` / `.hivemind` (same nearest-wins walk as + * routing, see src/dir-config.ts) and its `enabled` field: + * { "enabled": true } → opt this tree IN + * { "enabled": false } → turn Hivemind fully off for this tree + * + * Resolution: + * - nearest file says `enabled: false` → inactive (any mode) + * - mode `opt-in` and nearest file `enabled: true` → active + * - mode `opt-in` otherwise → inactive + * - mode `always` → active + * + * Fail direction: a corrupt `config.json` reads as the default mode; an + * unrecognized mode string also reads as the default. An unparseable + * `.hivemind` is skipped by the walk (same as routing). + */ + +import { findDirConfig, type FoundDirConfig } from "./dir-config.js"; +import { readUserConfig, writeUserConfig } from "./user-config.js"; + +export type ActivationMode = "always" | "opt-in"; + +export const ACTIVATION_MODES: readonly ActivationMode[] = ["always", "opt-in"] as const; + +export interface ActivationResult { + active: boolean; + mode: ActivationMode; + /** Where the mode came from, for `hivemind activation` / debug logs. */ + modeSource: "env" | "config" | "default"; + /** The `.hivemind` file that decided (if any). */ + found: FoundDirConfig | null; + /** Human-readable one-liner explaining the decision. */ + reason: string; +} + +export function parseActivationMode(v: unknown): ActivationMode | null { + if (typeof v !== "string") return null; + const s = v.trim().toLowerCase(); + if (s === "opt-in" || s === "optin" || s === "opt_in") return "opt-in"; + if (s === "always" || s === "on" || s === "default") return "always"; + return null; +} + +export function getActivationMode(): { mode: ActivationMode; source: ActivationResult["modeSource"] } { + const fromEnv = parseActivationMode(process.env.HIVEMIND_ACTIVATION); + if (fromEnv) return { mode: fromEnv, source: "env" }; + let fromCfg: ActivationMode | null = null; + try { + fromCfg = parseActivationMode(readUserConfig().activation?.mode); + } catch { + /* unreadable config → default */ + } + if (fromCfg) return { mode: fromCfg, source: "config" }; + return { mode: "always", source: "default" }; +} + +export function setActivationMode(mode: ActivationMode): void { + writeUserConfig({ activation: { mode } }); +} + +export function resolveActivation(cwd: string): ActivationResult { + const { mode, source } = getActivationMode(); + const found = cwd ? findDirConfig(cwd) : null; + const enabled = found?.raw.enabled; + + if (enabled === false) { + return { active: false, mode, modeSource: source, found, reason: `disabled by ${found!.path}` }; + } + if (mode === "opt-in") { + if (enabled === true) { + return { active: true, mode, modeSource: source, found, reason: `opted in by ${found!.path}` }; + } + return { + active: false, + mode, + modeSource: source, + found, + reason: found + ? `opt-in mode; nearest ${found.path} has no "enabled": true` + : "opt-in mode; no .hivemind / .hivemind.local with \"enabled\": true found", + }; + } + return { active: true, mode, modeSource: source, found, reason: "activation mode is always" }; +} + +/** + * Hook-side convenience: resolve + log. Returns true when the hook should run. + * Never throws — any unexpected failure fails OPEN in `always` mode and + * CLOSED in `opt-in` mode (a user who asked for opt-in expects silence). + */ +export function isHivemindActive(cwd: string, log?: (msg: string) => void): boolean { + try { + const r = resolveActivation(cwd); + if (!r.active) log?.(`hivemind inactive for cwd=${cwd || "?"}: ${r.reason}`); + return r.active; + } catch (e) { + let mode: ActivationMode = "always"; + try { mode = getActivationMode().mode; } catch { /* default */ } + log?.(`activation check failed (${(e as Error).message}); mode=${mode}`); + return mode !== "opt-in"; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 029a44d7..77ce3f84 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,6 +31,7 @@ import { runDocsCommand } from "../commands/docs.js"; import { runContextCommand } from "../commands/context.js"; import { runBackfillMemory } from "../commands/backfill-memory.js"; import { runFlushMemory } from "../commands/flush-memory.js"; +import { runActivationCommand } from "../commands/activation.js"; import { maybeAutoBackfillMemory } from "../skillify/spawn-backfill-memory-worker.js"; import { confirm, detectPlatforms, allPlatformIds, log, promptLine, warn, type PlatformId } from "./util.js"; import { getVersion } from "./version.js"; @@ -87,6 +88,12 @@ Usage: Run device-flow login (open browser). --ref attributes a new signup to a referrer code. hivemind status Show which assistants are wired up. + + hivemind activation [status | opt-in | always | enable | disable] + Control WHERE Hivemind runs. "opt-in" makes it fully inactive (no + context, recall, notifications or capture) except in directory trees + opted in with "hivemind activation enable". Run with no argument to see + whether Hivemind is active in the current directory and why. hivemind update [--dry-run] Check npm for a newer @deeplake/hivemind, upgrade the CLI, and refresh every detected agent bundle. Single command for all agents. @@ -517,6 +524,12 @@ async function main(): Promise { process.exit(code); } + if (cmd === "activation") { + const code = runActivationCommand(args.slice(1), { log, warn, cwd: process.cwd() }); + if (code !== 0) process.exit(code); + return; + } + if (cmd === "skillify") { runSkillifyCommand(args.slice(1)); return; diff --git a/src/commands/activation.ts b/src/commands/activation.ts new file mode 100644 index 00000000..1e4790eb --- /dev/null +++ b/src/commands/activation.ts @@ -0,0 +1,127 @@ +/** + * `hivemind activation` — where Hivemind is allowed to run at all. + * + * hivemind activation [status] show mode + decision for this dir + * hivemind activation opt-in inactive everywhere except opted-in trees + * hivemind activation always active everywhere (default) + * hivemind activation enable [--shared] [--dir ] + * hivemind activation disable [--shared] [--dir ] + * + * `enable` / `disable` write `"enabled": true|false` into `.hivemind.local` + * (personal, gitignored) in the target dir, or `.hivemind` (committed, team) + * with `--shared`. Existing fields in that file are preserved. See + * src/activation.ts for the resolution rules. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + ACTIVATION_MODES, + parseActivationMode, + resolveActivation, + setActivationMode, + type ActivationResult, +} from "../activation.js"; +import { parseDirConfig, type DirConfigFile } from "../dir-config.js"; + +export const ACTIVATION_USAGE = `Usage: + hivemind activation [status] [--dir ] Show whether Hivemind is active here, and why + hivemind activation opt-in Only run in trees that opted in (enabled: true) + hivemind activation always Run everywhere (default); enabled:false still opts out + hivemind activation enable [--shared] [--dir ] + Opt this directory tree in ("enabled": true) + hivemind activation disable [--shared] [--dir ] + Turn Hivemind fully off for this tree ("enabled": false) + + enable/disable write .hivemind.local (personal — add it to .gitignore); + --shared writes .hivemind instead (commit it to apply to the whole team).`; + +export interface ActivationIo { + log: (s: string) => void; + warn: (s: string) => void; + cwd: string; +} + +function flagValue(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +} + +export function renderActivationStatus(r: ActivationResult, dir: string): string { + const lines = [ + `Activation mode: ${r.mode}${r.modeSource === "env" ? " (from HIVEMIND_ACTIVATION)" : r.modeSource === "default" ? " (default)" : " (~/.deeplake/config.json)"}`, + `Directory: ${dir}`, + `Hivemind here: ${r.active ? "ACTIVE" : "INACTIVE"} — ${r.reason}`, + ]; + if (r.found) lines.push(`Nearest config: ${r.found.path} ${JSON.stringify(r.found.raw)}`); + return lines.join("\n"); +} + +/** Merge `patch` into the dir-config file at `path`, keeping its other fields. */ +export function writeDirConfigField(path: string, patch: DirConfigFile, seedFrom?: string): DirConfigFile { + let current: DirConfigFile = {}; + let source = existsSync(path) ? path : (seedFrom && existsSync(seedFrom) ? seedFrom : null); + if (source) { + const parsed = parseDirConfig(readFileSync(source, "utf-8")); + if (parsed === null && source === path) { + throw new Error(`${path} exists but is not a JSON object — fix or remove it first`); + } + current = parsed ?? {}; + } + const next = { ...current, ...patch }; + writeFileSync(path, JSON.stringify(next, null, 2) + "\n", "utf-8"); + return next; +} + +export function runActivationCommand(args: string[], io: ActivationIo): number { + const sub = args[0] && !args[0].startsWith("--") ? args[0] : "status"; + const dir = resolve(flagValue(args, "--dir") ?? io.cwd); + + if (sub === "status") { + io.log(renderActivationStatus(resolveActivation(dir), dir)); + return 0; + } + + const mode = parseActivationMode(sub); + if (mode) { + setActivationMode(mode); + io.log(`Activation mode set to "${mode}" in ~/.deeplake/config.json.`); + if (process.env.HIVEMIND_ACTIVATION && parseActivationMode(process.env.HIVEMIND_ACTIVATION) !== mode) { + io.warn(`Note: HIVEMIND_ACTIVATION=${process.env.HIVEMIND_ACTIVATION} is set in this environment and overrides the file.`); + } + if (mode === "opt-in") { + io.log("Hivemind is now inactive in every directory tree that has not opted in."); + io.log("Opt a repo in with: cd && hivemind activation enable"); + } + io.log("Takes effect on the next agent session (restart Cursor / start a new chat)."); + io.log(""); + io.log(renderActivationStatus(resolveActivation(dir), dir)); + return 0; + } + + if (sub === "enable" || sub === "disable") { + const shared = args.includes("--shared"); + const name = shared ? ".hivemind" : ".hivemind.local"; + const path = join(dir, name); + // A new .hivemind.local shadows a sibling .hivemind entirely (nearest file + // wins, no merge), so seed it from the committed file to keep its routing. + const seed = shared ? undefined : join(dir, ".hivemind"); + let written: DirConfigFile; + try { + written = writeDirConfigField(path, { enabled: sub === "enable" }, seed); + } catch (e) { + io.warn((e as Error).message); + return 1; + } + io.log(`Wrote ${path}: ${JSON.stringify(written)}`); + if (!shared) io.log(`Tip: add ".hivemind.local" to this repo's .gitignore — it's a personal setting.`); + const r = resolveActivation(dir); + io.log(""); + io.log(renderActivationStatus(r, dir)); + return 0; + } + + io.warn(`Unknown activation subcommand: ${sub} (expected: status | ${ACTIVATION_MODES.join(" | ")} | enable | disable)`); + io.log(ACTIVATION_USAGE); + return 1; +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 08fa9cc6..378e4725 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -16,6 +16,7 @@ import type { Config } from "../config.js"; import type { Credentials } from "./auth.js"; import { resolveDirConfig } from "../dir-config.js"; +import { resolveActivation } from "../activation.js"; const DEFAULT_API = "https://api.deeplake.ai"; @@ -54,7 +55,10 @@ export function renderWhoami(config: Config | null, creds: Credentials, cwd: str if (notes.length) { notes.push(`Stored identity: ${storedOrg} / ${storedWs}`); } - if (res.found && !res.collect) { + const act = resolveActivation(cwd); + if (!act.active) { + notes.push(`Hivemind: INACTIVE in this directory (${act.reason})`); + } else if (res.found && !res.collect) { notes.push(`Capture: disabled for this directory by ${res.found.path}`); } if (notes.length) lines.push("", ...notes); diff --git a/src/dir-config.ts b/src/dir-config.ts index b86a36cc..087b3831 100644 --- a/src/dir-config.ts +++ b/src/dir-config.ts @@ -31,6 +31,7 @@ import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { loadConfig, type Config } from "./config.js"; import { resolveWorkspaceRef } from "./commands/auth-creds.js"; +import { isHivemindActive } from "./activation.js"; /** Committed (shared) and local (personal, gitignored) filenames, local first. */ export const DIR_CONFIG_FILENAMES = [".hivemind.local", ".hivemind"] as const; @@ -41,6 +42,12 @@ export interface DirConfigFile { workspaceId?: string; /** false → never capture traces from this directory. Default true. */ collect?: boolean; + /** + * Activation switch (see src/activation.ts). `false` → Hivemind is fully + * inactive for this tree (no context, no recall, no capture, no network). + * `true` → opts this tree in when the global activation mode is `opt-in`. + */ + enabled?: boolean; } export interface FoundDirConfig { @@ -90,13 +97,18 @@ export function parseDirConfig(contents: string): DirConfigFile | null { 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; + if (typeof o.enabled === "boolean") out.enabled = o.enabled; 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. */ + /** + * false → caller must skip capture entirely for this cwd. Also false when + * Hivemind is inactive here (activation gate — `enabled: false`, or opt-in + * mode without an `enabled: true`), so every capture path honors it. + */ collect: boolean; /** The file that applied, if any (for the session-start banner / diagnostics). */ found: FoundDirConfig | null; @@ -134,7 +146,8 @@ export function resolveDirConfig( envOverride?: { HIVEMIND_ORG_ID?: string; HIVEMIND_WORKSPACE_ID?: string }, ): ResolvedDirConfig { const found = findDirConfig(cwd); - if (!found) return { config: base, collect: true, found: null }; + const active = isHivemindActive(cwd); + if (!found) return { config: base, collect: active, found: null }; const orgLocked = !!(envOverride ? envOverride.HIVEMIND_ORG_ID : process.env.HIVEMIND_ORG_ID); const envWs = envOverride ? envOverride.HIVEMIND_WORKSPACE_ID : process.env.HIVEMIND_WORKSPACE_ID; @@ -149,7 +162,7 @@ export function resolveDirConfig( orgName: orgLocked ? base.orgName : (found.raw.orgName ?? found.raw.orgId ?? base.orgName), workspaceId: resolveWorkspaceRef(base.workspaceAliases, orgId, wsRef), }; - return { config, collect: found.raw.collect !== false, found }; + return { config, collect: active && found.raw.collect !== false, found }; } /** diff --git a/src/hooks/capture.ts b/src/hooks/capture.ts index eed7f8b6..1b823472 100644 --- a/src/hooks/capture.ts +++ b/src/hooks/capture.ts @@ -8,6 +8,7 @@ */ import { readStdin } from "../utils/stdin.js"; +import { isHivemindActive } from "../activation.js"; import { type Config } from "../config.js"; import { resolveCaptureConfig } from "./shared/dir-gate.js"; import { redactSecrets } from "./shared/redact.js"; @@ -85,6 +86,9 @@ async function main(): Promise { if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } if (!entrypointPassesOnlyCliGate()) return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) 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); diff --git a/src/hooks/codex/capture.ts b/src/hooks/codex/capture.ts index 577b6aa8..f5d4a45f 100644 --- a/src/hooks/codex/capture.ts +++ b/src/hooks/codex/capture.ts @@ -13,6 +13,7 @@ */ import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { type Config } from "../../config.js"; import { resolveCaptureConfig } from "../shared/dir-gate.js"; import { redactSecrets } from "../shared/redact.js"; @@ -79,6 +80,9 @@ async function main(): Promise { if (!CAPTURE) return; if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; const config = resolveCaptureConfig(input.cwd ?? process.cwd(), log); if (!config) return; diff --git a/src/hooks/codex/pre-tool-use.ts b/src/hooks/codex/pre-tool-use.ts index 6cdaf61f..6f041b45 100644 --- a/src/hooks/codex/pre-tool-use.ts +++ b/src/hooks/codex/pre-tool-use.ts @@ -26,6 +26,7 @@ import { deriveProjectKey } from "../../utils/repo-identity.js"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { loadConfig } from "../../config.js"; import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; @@ -461,6 +462,9 @@ export async function processCodexPreToolUse( /* c8 ignore start */ async function main(): Promise { const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; // 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..9e3c24e3 100644 --- a/src/hooks/codex/session-start-setup.ts +++ b/src/hooks/codex/session-start-setup.ts @@ -14,6 +14,7 @@ import { loadConfig } from "../../config.js"; import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { createPlaceholderSummary } from "../shared/placeholder-summary.js"; import { log as _log } from "../../utils/debug.js"; import { makeWikiLogger } from "../../utils/wiki-log.js"; @@ -49,6 +50,9 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; // 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/codex/session-start.ts b/src/hooks/codex/session-start.ts index 5f518b7b..e1486806 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 { isHivemindActive } from "../../activation.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,9 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; let creds = loadCredentials(); let workspaceWarning = ""; diff --git a/src/hooks/codex/stop.ts b/src/hooks/codex/stop.ts index e88694e2..6e3b6d61 100644 --- a/src/hooks/codex/stop.ts +++ b/src/hooks/codex/stop.ts @@ -15,6 +15,7 @@ import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { loadConfig } from "../../config.js"; import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; @@ -53,6 +54,9 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; const sessionId = input.session_id; if (!sessionId) return; diff --git a/src/hooks/cursor/capture.ts b/src/hooks/cursor/capture.ts index 893bdd2b..df77b977 100644 --- a/src/hooks/cursor/capture.ts +++ b/src/hooks/cursor/capture.ts @@ -14,6 +14,8 @@ */ import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; +import { resolveCursorCwd } from "./cwd.js"; import { resolveCaptureConfig } from "../shared/dir-gate.js"; import { redactSecrets } from "../shared/redact.js"; import { DeeplakeApi } from "../../deeplake-api.js"; @@ -93,6 +95,9 @@ async function main(): Promise { if (!CAPTURE) return; if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(resolveCursorCwd(input), log)) return; const config = resolveCaptureConfig(resolveCwd(input), log); if (!config) return; diff --git a/src/hooks/cursor/cwd.ts b/src/hooks/cursor/cwd.ts new file mode 100644 index 00000000..d0447170 --- /dev/null +++ b/src/hooks/cursor/cwd.ts @@ -0,0 +1,15 @@ +/** + * The directory a Cursor hook event belongs to. + * + * Cursor runs user-level hooks (~/.cursor/hooks.json) with a process cwd that + * is NOT the open project, so `process.cwd()` must never be used to locate a + * `.hivemind` file. Prefer the per-event `cwd` (postToolUse / preToolUse), + * then the first workspace root (every event carries `workspace_roots`), and + * only fall back to the process cwd when Cursor sent neither. + */ +export function resolveCursorCwd(input: { cwd?: unknown; workspace_roots?: unknown }): string { + if (typeof input.cwd === "string" && input.cwd) return input.cwd; + const roots = input.workspace_roots; + if (Array.isArray(roots) && roots.length > 0 && typeof roots[0] === "string" && roots[0]) return roots[0]; + return process.cwd(); +} diff --git a/src/hooks/cursor/pre-tool-use.ts b/src/hooks/cursor/pre-tool-use.ts index 6950aa40..a430419b 100644 --- a/src/hooks/cursor/pre-tool-use.ts +++ b/src/hooks/cursor/pre-tool-use.ts @@ -27,6 +27,8 @@ */ import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; +import { resolveCursorCwd } from "./cwd.js"; import { deriveProjectKey } from "../../utils/repo-identity.js"; import { loadRoutedConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; @@ -54,6 +56,9 @@ interface CursorPreToolUseInput { async function main(): Promise { const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(resolveCursorCwd(input), log)) return; if (input.tool_name !== "Shell") return; // only intercept Shell, not Read/Write/MCP const command = (input.tool_input as CursorShellToolInput | undefined)?.command; @@ -70,7 +75,7 @@ async function main(): Promise { // would otherwise fall through and leave Cursor blind to the graph (the // exact gap that made Cursor silently lack graph queries). See // src/graph/graph-command.ts (shared with the Claude Code intercept). - const graphBody = tryGraphRead(rewritten, input.cwd ?? process.cwd()); + const graphBody = tryGraphRead(rewritten, resolveCursorCwd(input)); if (graphBody !== null) { log(`graph vfs intercept: ${command.slice(0, 80)}`); const echoCmd = `cat <<'__HIVEMIND_RESULT__'\n${graphBody}\n__HIVEMIND_RESULT__`; @@ -82,7 +87,7 @@ async function main(): Promise { return; } - const config = loadRoutedConfig(input.cwd ?? process.cwd()); + const config = loadRoutedConfig(resolveCursorCwd(input)); if (!config) { log("no config — falling through to Cursor's bash"); return; @@ -104,7 +109,7 @@ async function main(): Promise { // without a decision and let a memory-touching command reach the host shell. let docsBody: string | null = null; try { - docsBody = await tryDocsRead(rewritten, (sql) => api.query(sql), docsTable, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(input.cwd ?? process.cwd()).key }); + docsBody = await tryDocsRead(rewritten, (sql) => api.query(sql), docsTable, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(resolveCursorCwd(input)).key }); } catch (err) { log(`docs vfs failed: ${(err as Error).message}`); docsBody = "(docs temporarily unavailable — try again)"; diff --git a/src/hooks/cursor/session-end.ts b/src/hooks/cursor/session-end.ts index 5a665e8e..14d3c486 100644 --- a/src/hooks/cursor/session-end.ts +++ b/src/hooks/cursor/session-end.ts @@ -11,6 +11,8 @@ */ import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; +import { resolveCursorCwd } from "./cwd.js"; import { log as _log } from "../../utils/debug.js"; import { loadConfig } from "../../config.js"; import { resolveDirConfig } from "../../dir-config.js"; @@ -22,6 +24,8 @@ const log = (msg: string) => _log("cursor-session-end", msg); interface CursorSessionEndInput { conversation_id?: string; + cwd?: string; + workspace_roots?: string[]; session_id?: string; reason?: string; duration_ms?: number; @@ -31,12 +35,19 @@ interface CursorSessionEndInput { async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(resolveCursorCwd(input), log)) return; const sessionId = input.conversation_id ?? input.session_id ?? ""; log(`session=${sessionId || "?"} reason=${input.reason ?? "?"} status=${input.final_status ?? "?"}`); if (!sessionId) return; const base = loadConfig(); if (!base) { wikiLog(`SessionEnd: no config, skipping summary`); return; } - const dirRes = resolveDirConfig(base, process.cwd()); + // The project dir comes from the payload: Cursor runs user hooks with a + // process cwd that is not the open project, so resolving `.hivemind` from + // process.cwd() would miss a `collect: false` and summarize anyway. + const cwd = resolveCursorCwd(input); + 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 +57,7 @@ async function main(): Promise { try { forceSessionEndTrigger({ config, - cwd: process.cwd(), + cwd, bundleDir: bundleDirFromImportMeta(import.meta.url), agent: "cursor", sessionId, @@ -66,7 +77,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..8efa48d4 100644 --- a/src/hooks/cursor/session-start.ts +++ b/src/hooks/cursor/session-start.ts @@ -30,6 +30,8 @@ import { renderSkillifyCommands } from "../../cli/skillify-spec.js"; import { countLocalManifestEntries } from "../../skillify/local-manifest.js"; import { maybeAutoMineLocal } from "../../skillify/spawn-mine-local-worker.js"; import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; +import { resolveCursorCwd } from "./cwd.js"; import { log as _log } from "../../utils/debug.js"; import { getInstalledVersion } from "../../utils/version-check.js"; import { autoUpdate } from "../shared/autoupdate.js"; @@ -95,13 +97,7 @@ function resolveSessionId(input: CursorSessionStartInput): string { return input.session_id ?? input.conversation_id ?? `cursor-${Date.now()}`; } -function resolveCwd(input: CursorSessionStartInput): string { - const roots = input.workspace_roots; - if (Array.isArray(roots) && roots.length > 0 && typeof roots[0] === "string") { - return roots[0]; - } - return process.cwd(); -} +const resolveCwd = (input: CursorSessionStartInput): string => resolveCursorCwd(input); /** Create a placeholder summary via the shared race-safe writer (see placeholder-summary.ts). */ async function createPlaceholder( @@ -124,6 +120,9 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(resolveCursorCwd(input), log)) return; const sessionId = resolveSessionId(input); const cwd = resolveCwd(input); diff --git a/src/hooks/graph-on-stop.ts b/src/hooks/graph-on-stop.ts index bce4ea65..db0ecfc4 100644 --- a/src/hooks/graph-on-stop.ts +++ b/src/hooks/graph-on-stop.ts @@ -54,6 +54,7 @@ import { readLastBuild } from "../graph/last-build.js"; import { repoDir } from "../graph/snapshot.js"; import { isDirectRun } from "../utils/direct-run.js"; import { deriveProjectKey } from "../utils/repo-identity.js"; +import { isHivemindActive } from "../activation.js"; /** * Mirror of workTreeIdFor in src/commands/graph.ts. Kept inline (rather @@ -222,6 +223,10 @@ export async function main(deps: MainDeps = {}): Promise { envDisable, }; + // Activation gate: where Hivemind is inactive (opt-in mode without an + // `enabled: true`, or `enabled: false`) the hook does nothing at all. + if (!isHivemindActive(ctx.cwd)) return; + let decision: GateDecision; try { decision = gateFn(ctx); diff --git a/src/hooks/hermes/capture.ts b/src/hooks/hermes/capture.ts index 7d79ef14..0fd37088 100644 --- a/src/hooks/hermes/capture.ts +++ b/src/hooks/hermes/capture.ts @@ -15,6 +15,7 @@ */ import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { resolveCaptureConfig } from "../shared/dir-gate.js"; import { redactSecrets } from "../shared/redact.js"; import { DeeplakeApi } from "../../deeplake-api.js"; @@ -127,6 +128,9 @@ async function main(): Promise { if (!CAPTURE) return; if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; const config = resolveCaptureConfig(input.cwd ?? process.cwd(), log); if (!config) return; diff --git a/src/hooks/hermes/pre-tool-use.ts b/src/hooks/hermes/pre-tool-use.ts index 3e4b5f08..0e0ce2b0 100644 --- a/src/hooks/hermes/pre-tool-use.ts +++ b/src/hooks/hermes/pre-tool-use.ts @@ -21,6 +21,7 @@ */ import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { loadRoutedConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { log as _log } from "../../utils/debug.js"; @@ -41,6 +42,9 @@ interface HermesPreToolUseInput { async function main(): Promise { const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; // 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-end.ts b/src/hooks/hermes/session-end.ts index 418bb525..a6734ab9 100644 --- a/src/hooks/hermes/session-end.ts +++ b/src/hooks/hermes/session-end.ts @@ -6,6 +6,7 @@ */ import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { log as _log } from "../../utils/debug.js"; import { loadConfig } from "../../config.js"; import { resolveDirConfig } from "../../dir-config.js"; @@ -24,6 +25,9 @@ interface HermesSessionEndInput { async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; const sessionId = input.session_id ?? ""; log(`session=${sessionId || "?"} cwd=${input.cwd ?? "?"}`); if (!sessionId) return; diff --git a/src/hooks/hermes/session-start.ts b/src/hooks/hermes/session-start.ts index 8efef012..7a13ea22 100644 --- a/src/hooks/hermes/session-start.ts +++ b/src/hooks/hermes/session-start.ts @@ -21,6 +21,7 @@ import { renderSkillifyCommands } from "../../cli/skillify-spec.js"; import { countLocalManifestEntries } from "../../skillify/local-manifest.js"; import { maybeAutoMineLocal } from "../../skillify/spawn-mine-local-worker.js"; import { readStdin } from "../../utils/stdin.js"; +import { isHivemindActive } from "../../activation.js"; import { log as _log } from "../../utils/debug.js"; import { getInstalledVersion } from "../../utils/version-check.js"; import { autoUpdate } from "../shared/autoupdate.js"; @@ -90,6 +91,9 @@ async function createPlaceholder( async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; const sessionId = input.session_id ?? `hermes-${Date.now()}`; const cwd = input.cwd ?? process.cwd(); diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 363affed..fa206350 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -6,6 +6,7 @@ import { homedir } from "node:os"; import { join, dirname, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { readStdin } from "../utils/stdin.js"; +import { isHivemindActive } from "../activation.js"; import { loadConfig } from "../config.js"; import { resolveDirConfig } from "../dir-config.js"; import { armSkillOptOnSkillUse } from "./shared/skillopt-hook.js"; @@ -645,6 +646,9 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT /* c8 ignore start */ async function main(): Promise { const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; // 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-end.ts b/src/hooks/session-end.ts index e95d3578..9430ef00 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -9,6 +9,7 @@ */ import { readStdin } from "../utils/stdin.js"; +import { isHivemindActive } from "../activation.js"; import { loadConfig, type Config } from "../config.js"; import { resolveDirConfig } from "../dir-config.js"; import { log as _log } from "../utils/debug.js"; @@ -59,6 +60,9 @@ async function main(): Promise { if (!entrypointPassesOnlyCliGate()) return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; const sessionId = input.session_id; const cwd = input.cwd ?? ""; if (!sessionId) return; diff --git a/src/hooks/session-notifications.ts b/src/hooks/session-notifications.ts index 4d281899..24de5024 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 { isHivemindActive } from "../activation.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,9 @@ 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)); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input?.cwd ?? process.cwd(), log)) return; // 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..879cf0ec 100644 --- a/src/hooks/session-start-setup.ts +++ b/src/hooks/session-start-setup.ts @@ -13,6 +13,7 @@ import { loadCredentials, saveCredentials } from "../commands/auth.js"; import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { readStdin } from "../utils/stdin.js"; +import { isHivemindActive } from "../activation.js"; import { log as _log } from "../utils/debug.js"; import { makeWikiLogger } from "../utils/wiki-log.js"; import { EmbedClient } from "../embeddings/client.js"; @@ -33,6 +34,9 @@ async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; // 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..76cab7c9 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -17,6 +17,7 @@ import { loadConfig } from "../config.js"; import { resolveDirConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { readStdin } from "../utils/stdin.js"; +import { isHivemindActive } from "../activation.js"; import { log as _log } from "../utils/debug.js"; import { getInstalledVersion } from "../utils/version-check.js"; import { makeWikiLogger } from "../utils/wiki-log.js"; @@ -124,6 +125,9 @@ async function main(): Promise { log(`hook entered (pid=${process.pid})`); const input = await readStdin(); + // Activation gate: Hivemind stays fully silent (no context, recall, network + // or capture) where it isn't active — see src/activation.ts. + if (!isHivemindActive(input.cwd ?? process.cwd(), log)) return; // 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 diff --git a/src/user-config.ts b/src/user-config.ts index d66385d5..40b74039 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -21,6 +21,15 @@ export interface UserConfig { /** Which host CLI authors the docs (claude | codex | pi | cursor). */ llmAgent?: string; }; + activation?: { + /** + * "always" (default) → Hivemind runs everywhere unless a `.hivemind` says + * `enabled: false`. "opt-in" → Hivemind is inactive except in trees whose + * nearest `.hivemind` / `.hivemind.local` says `enabled: true`. + * See src/activation.ts. + */ + mode?: string; + }; } let _configPath: () => string = () => diff --git a/tests/shared/activation-gate-coverage.test.ts b/tests/shared/activation-gate-coverage.test.ts new file mode 100644 index 00000000..493e4385 --- /dev/null +++ b/tests/shared/activation-gate-coverage.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * COVERAGE GUARD for the activation gate (src/activation.ts). + * + * "Opt-in" only means something if EVERY agent hook that can inject context, + * hit the network, or capture checks it. A single ungated hook (e.g. a new + * session-start variant) silently breaks the promise to users who asked for + * Hivemind to be inactive outside opted-in repos. This test lists every hook + * entrypoint the harness installers register and requires an + * `isHivemindActive(` call in each. + */ + +const __dir = fileURLToPath(new URL(".", import.meta.url)); +const SRC = join(__dir, "..", "..", "src"); + +const GATED_HOOKS = [ + // Claude Code + "hooks/session-start.ts", + "hooks/session-start-setup.ts", + "hooks/session-notifications.ts", + "hooks/pre-tool-use.ts", + "hooks/capture.ts", + "hooks/session-end.ts", + "hooks/graph-on-stop.ts", + // Codex + "hooks/codex/session-start.ts", + "hooks/codex/session-start-setup.ts", + "hooks/codex/capture.ts", + "hooks/codex/pre-tool-use.ts", + "hooks/codex/stop.ts", + // Cursor + "hooks/cursor/session-start.ts", + "hooks/cursor/capture.ts", + "hooks/cursor/pre-tool-use.ts", + "hooks/cursor/session-end.ts", + // Hermes + "hooks/hermes/session-start.ts", + "hooks/hermes/capture.ts", + "hooks/hermes/pre-tool-use.ts", + "hooks/hermes/session-end.ts", +]; + +describe("activation gate coverage", () => { + for (const rel of GATED_HOOKS) { + it(`${rel} calls isHivemindActive`, () => { + expect(readFileSync(join(SRC, rel), "utf-8")).toMatch(/isHivemindActive\(/); + }); + } + + it("cursor hooks never resolve .hivemind from a bare process.cwd()", () => { + for (const f of ["session-end.ts", "pre-tool-use.ts", "session-start.ts"]) { + const body = readFileSync(join(SRC, "hooks", "cursor", f), "utf-8"); + expect(body, f).not.toMatch(/resolveDirConfig\([^)]*process\.cwd\(\)/); + expect(body, f).not.toMatch(/loadRoutedConfig\([^)]*process\.cwd\(\)/); + } + }); + + it("pi extension gates every lifecycle handler and tool", () => { + const body = readFileSync(join(__dir, "..", "..", "harnesses", "pi", "extension-source", "hivemind.ts"), "utf-8"); + for (const ev of ["session_start", "input", "tool_result", "message_end", "session_shutdown"]) { + const i = body.indexOf(`pi.on("${ev}"`); + expect(i, ev).toBeGreaterThan(-1); + expect(body.slice(i, i + 300), ev).toMatch(/piIsActive\(/); + } + expect(body.match(/if \(!piIsActive\(process\.cwd\(\)\)\) return textResult/g)?.length).toBe(3); + }); +}); diff --git a/tests/shared/activation.test.ts b/tests/shared/activation.test.ts new file mode 100644 index 00000000..298a0436 --- /dev/null +++ b/tests/shared/activation.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Config } from "../../src/config.js"; +import { + getActivationMode, + isHivemindActive, + parseActivationMode, + resolveActivation, + setActivationMode, +} from "../../src/activation.js"; +import { parseDirConfig, resolveDirConfig } from "../../src/dir-config.js"; +import { _setConfigPathForTesting, _resetUserConfigForTesting } from "../../src/user-config.js"; +import { runActivationCommand } from "../../src/commands/activation.js"; +import { resolveCursorCwd } from "../../src/hooks/cursor/cwd.js"; + +let root: string; +let cfgPath: string; +let savedEnv: string | undefined; + +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", + codebaseTableName: "codebase", + docsTableName: "docs", + memoryPath: "/tmp/mem", + }; +} + +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)); +} + +function setMode(mode: string | null): void { + writeFileSync(cfgPath, JSON.stringify(mode === null ? {} : { activation: { mode } })); + _setConfigPathForTesting(() => cfgPath); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "hivemind-activation-")); + cfgPath = join(root, "config.json"); + savedEnv = process.env.HIVEMIND_ACTIVATION; + delete process.env.HIVEMIND_ACTIVATION; + setMode(null); +}); + +afterEach(() => { + if (savedEnv === undefined) delete process.env.HIVEMIND_ACTIVATION; + else process.env.HIVEMIND_ACTIVATION = savedEnv; + _resetUserConfigForTesting(); + rmSync(root, { recursive: true, force: true }); +}); + +describe("parseActivationMode", () => { + it("accepts opt-in spellings and always spellings, rejects garbage", () => { + for (const v of ["opt-in", "OPT-IN", " optin ", "opt_in"]) expect(parseActivationMode(v)).toBe("opt-in"); + for (const v of ["always", "on", "default"]) expect(parseActivationMode(v)).toBe("always"); + for (const v of ["", "nope", 1, null, undefined, {}]) expect(parseActivationMode(v)).toBeNull(); + }); +}); + +describe("getActivationMode", () => { + it("defaults to always (existing installs unchanged)", () => { + expect(getActivationMode()).toEqual({ mode: "always", source: "default" }); + }); + + it("reads config.json", () => { + setMode("opt-in"); + expect(getActivationMode()).toEqual({ mode: "opt-in", source: "config" }); + }); + + it("HIVEMIND_ACTIVATION env overrides config.json", () => { + setMode("opt-in"); + process.env.HIVEMIND_ACTIVATION = "always"; + expect(getActivationMode()).toEqual({ mode: "always", source: "env" }); + }); + + it("unknown mode string in config falls back to default", () => { + setMode("sometimes"); + expect(getActivationMode().mode).toBe("always"); + }); + + it("corrupt config.json falls back to default", () => { + writeFileSync(cfgPath, "{not json"); + _setConfigPathForTesting(() => cfgPath); + expect(getActivationMode().mode).toBe("always"); + }); + + it("setActivationMode persists without clobbering other settings", () => { + writeFileSync(cfgPath, JSON.stringify({ embeddings: { enabled: true } })); + _setConfigPathForTesting(() => cfgPath); + setActivationMode("opt-in"); + expect(JSON.parse(readFileSync(cfgPath, "utf-8"))).toEqual({ + embeddings: { enabled: true }, + activation: { mode: "opt-in" }, + }); + }); +}); + +describe("parseDirConfig enabled field", () => { + it("keeps boolean enabled, drops non-boolean", () => { + expect(parseDirConfig('{"enabled": true}')).toEqual({ enabled: true }); + expect(parseDirConfig('{"enabled": false, "collect": true}')).toEqual({ enabled: false, collect: true }); + expect(parseDirConfig('{"enabled": "yes"}')).toEqual({}); + }); +}); + +describe("resolveActivation — always mode (default)", () => { + it("active with no .hivemind anywhere", () => { + expect(resolveActivation(dir("a", "b")).active).toBe(true); + }); + + it("collect:false alone does NOT deactivate (read-only recipe still works)", () => { + const src = dir("src"); + write(src, ".hivemind", { collect: false }); + expect(resolveActivation(dir("src", "repo")).active).toBe(true); + }); + + it("enabled:false deactivates the whole tree below it", () => { + const src = dir("src"); + write(src, ".hivemind", { enabled: false }); + const r = resolveActivation(dir("src", "repo", "pkg")); + expect(r.active).toBe(false); + expect(r.reason).toContain(join(src, ".hivemind")); + }); + + it("nearer enabled:true re-enables below a disabled ancestor", () => { + write(dir("src"), ".hivemind", { enabled: false }); + write(dir("src", "dl"), ".hivemind.local", { enabled: true }); + expect(resolveActivation(dir("src", "dl", "x")).active).toBe(true); + expect(resolveActivation(dir("src", "other")).active).toBe(false); + }); +}); + +describe("resolveActivation — opt-in mode", () => { + beforeEach(() => setMode("opt-in")); + + it("inactive with no .hivemind anywhere", () => { + const r = resolveActivation(dir("random", "repo")); + expect(r.active).toBe(false); + expect(r.mode).toBe("opt-in"); + }); + + it("customer scenario: root collect:false, unrelated repo → inactive; opted-in repo → active", () => { + write(dir("src"), ".hivemind", { collect: false }); + write(dir("src", "deeplake"), ".hivemind.local", { enabled: true }); + expect(resolveActivation(dir("src", "unrelated")).active).toBe(false); + expect(resolveActivation(dir("src", "deeplake", "python", "deeplake")).active).toBe(true); + }); + + it("a routing-only .hivemind does not opt in (must be explicit)", () => { + write(dir("team"), ".hivemind", { orgId: "acme", workspaceId: "w" }); + expect(resolveActivation(dir("team")).active).toBe(false); + }); + + it("committed .hivemind with enabled:true opts in the whole team repo", () => { + write(dir("repo"), ".hivemind", { enabled: true, workspaceId: "w" }); + expect(resolveActivation(dir("repo", "sub")).active).toBe(true); + }); + + it(".hivemind.local enabled:false beats a committed enabled:true in the same dir", () => { + const repo = dir("repo"); + write(repo, ".hivemind", { enabled: true }); + write(repo, ".hivemind.local", { enabled: false }); + expect(resolveActivation(repo).active).toBe(false); + }); + + it("empty cwd is inactive (never falls back to the process cwd)", () => { + expect(resolveActivation("").active).toBe(false); + }); + + it("HIVEMIND_ACTIVATION=always overrides the file for one process", () => { + process.env.HIVEMIND_ACTIVATION = "always"; + expect(resolveActivation(dir("random")).active).toBe(true); + }); +}); + +describe("isHivemindActive", () => { + it("logs the reason when inactive", () => { + setMode("opt-in"); + const lines: string[] = []; + expect(isHivemindActive(dir("x"), (m) => lines.push(m))).toBe(false); + expect(lines.join("\n")).toMatch(/inactive/); + }); + + it("stays silent when active", () => { + const lines: string[] = []; + expect(isHivemindActive(dir("x"), (m) => lines.push(m))).toBe(true); + expect(lines).toEqual([]); + }); +}); + +describe("resolveDirConfig honors activation for capture", () => { + it("opt-in mode with no opt-in → collect false (every capture path skips)", () => { + setMode("opt-in"); + expect(resolveDirConfig(base(), dir("repo")).collect).toBe(false); + }); + + it("opt-in mode with enabled:true → collect true, routing still applied", () => { + setMode("opt-in"); + const repo = dir("repo"); + write(repo, ".hivemind.local", { enabled: true, workspaceId: "dl" }); + const r = resolveDirConfig(base(), repo); + expect(r.collect).toBe(true); + expect(r.config.workspaceId).toBe("dl"); + }); + + it("enabled:true + collect:false → still no capture", () => { + setMode("opt-in"); + const repo = dir("repo"); + write(repo, ".hivemind", { enabled: true, collect: false }); + expect(resolveDirConfig(base(), repo).collect).toBe(false); + }); + + it("always mode, no file → collect true (unchanged default)", () => { + expect(resolveDirConfig(base(), dir("repo")).collect).toBe(true); + }); + + it("always mode, enabled:false → collect false", () => { + const repo = dir("repo"); + write(repo, ".hivemind", { enabled: false }); + expect(resolveDirConfig(base(), repo).collect).toBe(false); + }); +}); + +describe("resolveCursorCwd", () => { + it("prefers payload cwd, then first workspace root, then process cwd", () => { + expect(resolveCursorCwd({ cwd: "/a", workspace_roots: ["/b"] })).toBe("/a"); + expect(resolveCursorCwd({ workspace_roots: ["/b", "/c"] })).toBe("/b"); + expect(resolveCursorCwd({ cwd: "", workspace_roots: [] })).toBe(process.cwd()); + expect(resolveCursorCwd({})).toBe(process.cwd()); + }); +}); + +describe("hivemind activation CLI", () => { + function run(args: string[], cwd: string) { + const out: string[] = []; + const err: string[] = []; + const code = runActivationCommand(args, { log: (s) => out.push(s), warn: (s) => err.push(s), cwd }); + return { code, out: out.join("\n"), err: err.join("\n") }; + } + + it("status reports mode + decision", () => { + const r = run([], dir("repo")); + expect(r.code).toBe(0); + expect(r.out).toMatch(/Activation mode: always \(default\)/); + expect(r.out).toMatch(/Hivemind here:\s+ACTIVE/); + }); + + it("opt-in persists mode and reports INACTIVE here", () => { + const r = run(["opt-in"], dir("repo")); + expect(r.code).toBe(0); + expect(JSON.parse(readFileSync(cfgPath, "utf-8")).activation.mode).toBe("opt-in"); + expect(r.out).toMatch(/INACTIVE/); + }); + + it("enable writes .hivemind.local, seeding routing from a sibling .hivemind", () => { + setMode("opt-in"); + const repo = dir("repo"); + write(repo, ".hivemind", { workspaceId: "dl-team" }); + const r = run(["enable"], repo); + expect(r.code).toBe(0); + expect(JSON.parse(readFileSync(join(repo, ".hivemind.local"), "utf-8"))).toEqual({ workspaceId: "dl-team", enabled: true }); + expect(r.out).toMatch(/ACTIVE — opted in/); + // committed file untouched + expect(JSON.parse(readFileSync(join(repo, ".hivemind"), "utf-8"))).toEqual({ workspaceId: "dl-team" }); + }); + + it("enable --shared writes .hivemind and preserves its fields", () => { + const repo = dir("repo"); + write(repo, ".hivemind", { orgId: "acme" }); + run(["enable", "--shared"], repo); + expect(JSON.parse(readFileSync(join(repo, ".hivemind"), "utf-8"))).toEqual({ orgId: "acme", enabled: true }); + expect(existsSync(join(repo, ".hivemind.local"))).toBe(false); + }); + + it("disable --dir targets another directory", () => { + const other = dir("other"); + const r = run(["disable", "--dir", other], dir("here")); + expect(r.code).toBe(0); + expect(JSON.parse(readFileSync(join(other, ".hivemind.local"), "utf-8"))).toEqual({ enabled: false }); + expect(resolveActivation(other).active).toBe(false); + }); + + it("refuses to overwrite a corrupt existing file", () => { + const repo = dir("repo"); + write(repo, ".hivemind.local", "not json"); + const r = run(["enable"], repo); + expect(r.code).toBe(1); + expect(r.err).toMatch(/not a JSON object/); + expect(readFileSync(join(repo, ".hivemind.local"), "utf-8")).toBe("not json"); + }); + + it("unknown subcommand exits 1", () => { + expect(run(["bogus"], dir("repo")).code).toBe(1); + }); +});