From c48cf9548a23d7825d66e45219bbf28f9423fd88 Mon Sep 17 00:00:00 2001 From: Levon Ghukasyan Date: Sat, 26 Sep 2026 11:53:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20opt-in=20activation=20mode=20=E2=80=94?= =?UTF-8?q?=20keep=20Hivemind=20fully=20inactive=20outside=20chosen=20repo?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer set `{"collect": false}` at the root of their source tree and still saw Cursor connect to Hivemind in unrelated repos. `collect` only gates writes: session start still authenticated, ran autoupdate, read rules/goals/memory, pulled skills, fetched notifications and injected the Hivemind context (7 API calls per Cursor sessionStart, reproduced). There was no way to make Hivemind inactive except in opted-in directories. - src/activation.ts: new gate. Global mode `always` (default, unchanged) or `opt-in` via ~/.deeplake/config.json `activation.mode` or HIVEMIND_ACTIVATION. New `.hivemind` field `enabled`: false → fully inactive in that tree; true → opts the tree in under opt-in mode. Nearest-file-wins, same as routing. - Every Claude Code / Codex / Cursor / Hermes hook entrypoint and the pi extension exit before doing anything when inactive (no context, no network, no capture). resolveDirConfig().collect also honors the gate so all capture paths (graph push, workers) are covered. - `hivemind activation [status|opt-in|always|enable|disable]` CLI; whoami reports an inactive directory. - Fix: Cursor sessionEnd resolved `.hivemind` from process.cwd() (Cursor runs user hooks from ~/.cursor), so it ignored `collect: false` and spawned the skillify worker; now uses the payload's workspace root. - openclaw graph bundles: route HIVEMIND_ACTIVATION through the tuning dispatch to keep the ClawHub env-harvesting audit clean. - Tests: activation unit tests + a coverage guard requiring the gate in every hook entrypoint. README documents opt-in mode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DwKab1yFq6WhyjAqxC1mYr --- README.md | 43 +++ esbuild.config.mjs | 3 + harnesses/pi/extension-source/hivemind.ts | 45 ++- src/activation.ts | 118 +++++++ src/cli/index.ts | 13 + src/commands/activation.ts | 127 +++++++ src/commands/whoami.ts | 6 +- src/dir-config.ts | 19 +- src/hooks/capture.ts | 4 + src/hooks/codex/capture.ts | 4 + src/hooks/codex/pre-tool-use.ts | 4 + src/hooks/codex/session-start-setup.ts | 4 + src/hooks/codex/session-start.ts | 4 + src/hooks/codex/stop.ts | 4 + src/hooks/cursor/capture.ts | 5 + src/hooks/cursor/cwd.ts | 15 + src/hooks/cursor/pre-tool-use.ts | 11 +- src/hooks/cursor/session-end.ts | 17 +- src/hooks/cursor/session-start.ts | 13 +- src/hooks/graph-on-stop.ts | 5 + src/hooks/hermes/capture.ts | 4 + src/hooks/hermes/pre-tool-use.ts | 4 + src/hooks/hermes/session-end.ts | 4 + src/hooks/hermes/session-start.ts | 4 + src/hooks/pre-tool-use.ts | 4 + src/hooks/session-end.ts | 4 + src/hooks/session-notifications.ts | 4 + src/hooks/session-start-setup.ts | 4 + src/hooks/session-start.ts | 4 + src/user-config.ts | 9 + tests/shared/activation-gate-coverage.test.ts | 71 ++++ tests/shared/activation.test.ts | 314 ++++++++++++++++++ 32 files changed, 876 insertions(+), 18 deletions(-) create mode 100644 src/activation.ts create mode 100644 src/commands/activation.ts create mode 100644 src/hooks/cursor/cwd.ts create mode 100644 tests/shared/activation-gate-coverage.test.ts create mode 100644 tests/shared/activation.test.ts diff --git a/README.md b/README.md index 662be0a50..843b4602d 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 a24d1e417..6f60a9a65 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 20594980f..dcea5e061 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 000000000..3a44b6720 --- /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 029a44d7f..77ce3f843 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 000000000..1e4790eb0 --- /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 08fa9cc60..378e4725a 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 b86a36cc9..087b3831e 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 eed7f8b65..1b8234721 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 577b6aa80..f5d4a45f7 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 6cdaf61f1..6f041b45e 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 72fb6a0a4..9e3c24e3c 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 5f518b7b3..e1486806f 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 e88694e25..6e3b6d618 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 893bdd2bd..df77b977f 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 000000000..d04471700 --- /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 6950aa406..a430419b2 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 5a665e8e7..14d3c4863 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 c7ea65357..8efa48d43 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 bce4ea652..db0ecfc43 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 7d79ef145..0fd37088f 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 3e4b5f083..0e0ce2b06 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 418bb525a..a6734ab9c 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 8efef0121..7a13ea224 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 363affed9..fa206350e 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 e95d35781..9430ef00e 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 4d2818993..24de50244 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 192b10815..879cf0ec6 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 72ad5852d..76cab7c98 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 d66385d56..40b740393 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 000000000..493e43852 --- /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 000000000..298a0436d --- /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); + }); +});