diff --git a/src/commands/context.ts b/src/commands/context.ts index 5433e998..ca6fe22d 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -22,7 +22,7 @@ * maxRules / maxGoals defaults of 10 are the v1 contract). */ -import { loadConfig } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { renderContextBlock } from "../hooks/shared/context-renderer.js"; @@ -46,7 +46,7 @@ export async function runContextCommand(args: string[]): Promise { return; } - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { console.error("Not logged in. Run `hivemind login` first."); process.exit(2); diff --git a/src/commands/flush-memory.ts b/src/commands/flush-memory.ts index 387e3cdc..1055e9d2 100644 --- a/src/commands/flush-memory.ts +++ b/src/commands/flush-memory.ts @@ -19,7 +19,8 @@ import { existsSync, readFileSync } from "node:fs"; -import { loadConfig, type Config } from "../config.js"; +import { type Config } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { uploadSummary, type QueryFn, type UploadParams, type UploadResult } from "../hooks/upload-summary.js"; import { EmbedClient } from "../embeddings/client.js"; @@ -78,7 +79,9 @@ async function defaultEmbed(text: string): Promise { export function defaultDeps(pluginVersion?: string): FlushDeps { return { - loadConfig, + // Route by cwd so a flush targets the directory's `.hivemind` workspace, + // consistent with capture (see src/dir-config.ts). + loadConfig: loadRoutedConfig, makeQuery: (config) => (sql: string) => new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, config.tableName).query(sql), diff --git a/src/commands/goal.ts b/src/commands/goal.ts index ea51f869..0583362c 100644 --- a/src/commands/goal.ts +++ b/src/commands/goal.ts @@ -35,7 +35,7 @@ */ import { randomUUID } from "node:crypto"; -import { loadConfig } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { sqlIdent, sqlStr } from "../utils/sql.js"; @@ -78,7 +78,7 @@ function parseAgentFlag(args: string[]): { agent: string; rest: string[] } { } function loadApiOrDie(table: string): { api: DeeplakeApi; query: QueryFn; userName: string } { - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("hivemind: not logged in. Run `hivemind login` first.\n"); process.exit(1); @@ -97,7 +97,7 @@ function loadApiOrDie(table: string): { api: DeeplakeApi; query: QueryFn; userNa // ── goal subcommands ──────────────────────────────────────────────────────── async function goalAdd(text: string, agent: string = "manual"): Promise { - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("hivemind: not logged in.\n"); process.exit(1); @@ -126,7 +126,7 @@ async function goalAdd(text: string, agent: string = "manual"): Promise { } async function goalList(filter: "all" | "mine"): Promise { - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); } const { query } = loadApiOrDie(cfg.goalsTableName); const safe = sqlIdent(cfg.goalsTableName); @@ -152,7 +152,7 @@ async function goalList(filter: "all" | "mine"): Promise { async function goalGet(goalId: string): Promise { if (!goalId) { process.stderr.write("usage: hivemind goal get \n"); process.exit(1); } - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); } const { query } = loadApiOrDie(cfg.goalsTableName); const safe = sqlIdent(cfg.goalsTableName); @@ -183,7 +183,7 @@ async function goalProgress(goalId: string, status: string): Promise { process.stderr.write(`invalid status: ${status} (expected opened|in_progress|closed)\n`); process.exit(1); } - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); } const { api, query } = loadApiOrDie(cfg.goalsTableName); // Heal the schema before the UPDATE: an upgraded workspace's preexisting @@ -212,7 +212,7 @@ async function kpiAdd(args: string[]): Promise { process.exit(1); } const name = nameParts.length > 0 ? nameParts.join(" ") : kpiId; - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); } const { api, query } = loadApiOrDie(cfg.kpisTableName); await api.ensureKpisTable(cfg.kpisTableName); @@ -237,7 +237,7 @@ async function kpiAdd(args: string[]): Promise { async function kpiList(goalId: string): Promise { if (!goalId) { process.stderr.write("usage: hivemind kpi list \n"); process.exit(1); } - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); } const { query } = loadApiOrDie(cfg.kpisTableName); const safe = sqlIdent(cfg.kpisTableName); @@ -266,7 +266,7 @@ async function kpiBump(goalId: string, kpiId: string, deltaStr: string): Promise process.stderr.write(`invalid delta: ${deltaStr}\n`); process.exit(1); } - const cfg = loadConfig(); + const cfg = loadRoutedConfig(); if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); } const { api, query } = loadApiOrDie(cfg.kpisTableName); // Heal the schema before the UPDATE — same reason as goalProgress: a diff --git a/src/commands/rules.ts b/src/commands/rules.ts index 441ce091..8ca3a1dd 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -22,7 +22,7 @@ * (see ./rules-module commit). */ -import { loadConfig } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { getVersion } from "../cli/version.js"; import { @@ -52,8 +52,8 @@ function logUsageAndExit(code = 1): never { throw new Error("unreachable"); } -function requireConfig(): ReturnType & object { - const cfg = loadConfig(); +function requireConfig(): ReturnType & object { + const cfg = loadRoutedConfig(); if (!cfg) { console.error("Not logged in. Run `hivemind login` first."); process.exit(2); @@ -62,7 +62,7 @@ function requireConfig(): ReturnType & object { return cfg; } -function makeApi(cfg: NonNullable>): DeeplakeApi { +function makeApi(cfg: NonNullable>): DeeplakeApi { return new DeeplakeApi( cfg.token, cfg.apiUrl, diff --git a/src/commands/skillify.ts b/src/commands/skillify.ts index 8393d861..e7f5fc9f 100644 --- a/src/commands/skillify.ts +++ b/src/commands/skillify.ts @@ -30,6 +30,7 @@ import { runPull, type PullSummary } from "../skillify/pull.js"; import { runPush } from "../skillify/push.js"; import { runUnpull } from "../skillify/unpull.js"; import { loadConfig } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { runMineLocal } from "./mine-local.js"; import { renderSubcommandUsageBlock } from "../cli/skillify-spec.js"; @@ -221,7 +222,7 @@ async function pullSkills(args: string[]): Promise { else if (userOne) users = [userOne]; else if (usersMany) users = usersMany.split(",").map(s => s.trim()).filter(Boolean); - const config = loadConfig(); + const config = loadRoutedConfig(); if (!config) { console.error("Not logged in. Run: hivemind login"); process.exit(1); @@ -285,7 +286,7 @@ async function pushSkills(args: string[]): Promise { throw new Error("Usage: hivemind skillify push [--from project|global] [--dry-run]"); } - const config = loadConfig(); + const config = loadRoutedConfig(); if (!config) { throw new Error("Not logged in. Run: hivemind login"); } diff --git a/src/dir-config.ts b/src/dir-config.ts index e582cda4..a7d3c0af 100644 --- a/src/dir-config.ts +++ b/src/dir-config.ts @@ -29,7 +29,7 @@ import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; -import type { Config } from "./config.js"; +import { loadConfig, type Config } from "./config.js"; /** Committed (shared) and local (personal, gitignored) filenames, local first. */ export const DIR_CONFIG_FILENAMES = [".hivemind.local", ".hivemind"] as const; @@ -145,3 +145,27 @@ export function resolveDirConfig( }; return { config, collect: found.raw.collect !== false, found }; } + +/** + * THE single entry point for a workspace-scoped Config. + * + * Any code path that builds a `DeeplakeApi` against per-directory workspace data + * — CLI commands (goals, rules, skills), memory read/write hooks, recall — MUST + * get its config from here, never from a bare `loadConfig()`. It folds the + * nearest `.hivemind` (and the `HIVEMIND_*` env locks, via resolveDirConfig) + * into one place, so routing can never again be half-wired across call sites. + * + * Drop-in for `loadConfig()`: same `Config | null` shape, `cwd` defaults to the + * process cwd (correct for every CLI command). Hooks that carry an explicit + * session cwd pass it in. `collect` is intentionally NOT surfaced here — it + * gates capture only; callers on the capture path use `resolveDirConfig` + * directly so they can honor the opt-out. + * + * The guard in tests/shared/dir-config-single-source.test.ts enforces that + * workspace-touching modules call this and not `loadConfig()`. + */ +export function loadRoutedConfig(cwd: string = process.cwd()): Config | null { + const base = loadConfig(); + if (!base) return null; + return resolveDirConfig(base, cwd).config; +} diff --git a/src/graph/deeplake-pull.ts b/src/graph/deeplake-pull.ts index fbc7b4c9..b627fd97 100644 --- a/src/graph/deeplake-pull.ts +++ b/src/graph/deeplake-pull.ts @@ -47,7 +47,8 @@ function workTreeIdFor(cwd: string): string { return createHash("sha256").update(cwd).digest("hex").slice(0, 16); } -import { loadConfig, type Config } from "../config.js"; +import { type Config } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { sqlIdent, sqlStr } from "../utils/sql.js"; import { deriveProjectKey } from "../utils/repo-identity.js"; @@ -95,7 +96,9 @@ export async function pullSnapshot( if (process.env.HIVEMIND_GRAPH_PULL === "0") { return { kind: "skipped-disabled" }; } - const config = (deps.loadConfig ?? loadConfig)(); + // Route by the caller's cwd so a pull lands from the same `.hivemind` + // workspace the graph was pushed to (deeplake-push routes identically). + const config = (deps.loadConfig ?? (() => loadRoutedConfig(cwd)))(); if (config === null) { return { kind: "skipped-no-auth" }; } diff --git a/src/hooks/codex/pre-tool-use.ts b/src/hooks/codex/pre-tool-use.ts index 2d53d26f..6cdaf61f 100644 --- a/src/hooks/codex/pre-tool-use.ts +++ b/src/hooks/codex/pre-tool-use.ts @@ -27,6 +27,7 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { readStdin } from "../../utils/stdin.js"; import { loadConfig } from "../../config.js"; +import { resolveDirConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { sqlLike } from "../../utils/sql.js"; import { parseBashGrep, handleGrepDirect } from "../grep-direct.js"; @@ -120,7 +121,7 @@ export async function processCodexPreToolUse( deps: CodexPreToolDeps = {}, ): Promise { const { - config = loadConfig(), + config: baseConfig = loadConfig(), createApi = (table, activeConfig) => new DeeplakeApi( activeConfig.token, activeConfig.apiUrl, @@ -148,6 +149,12 @@ export async function processCodexPreToolUse( logFn = log, } = deps; + // Route memory reads/writes through the nearest `.hivemind`, like capture — + // a directory pinned to another workspace must read/write THAT workspace, not + // the global one. Applied to the injected/default base alike (a no-op when no + // `.hivemind` applies). See src/dir-config.ts. + const config = baseConfig ? resolveDirConfig(baseConfig, input.cwd ?? process.cwd()).config : baseConfig; + const cmd = input.tool_input?.command ?? ""; logFn(`hook fired: cmd=${cmd}`); diff --git a/src/hooks/cursor/pre-tool-use.ts b/src/hooks/cursor/pre-tool-use.ts index 5236a30a..6950aa40 100644 --- a/src/hooks/cursor/pre-tool-use.ts +++ b/src/hooks/cursor/pre-tool-use.ts @@ -28,7 +28,7 @@ import { readStdin } from "../../utils/stdin.js"; import { deriveProjectKey } from "../../utils/repo-identity.js"; -import { loadConfig } from "../../config.js"; +import { loadRoutedConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { log as _log } from "../../utils/debug.js"; import { parseBashGrep, handleGrepDirect } from "../grep-direct.js"; @@ -82,7 +82,7 @@ async function main(): Promise { return; } - const config = loadConfig(); + const config = loadRoutedConfig(input.cwd ?? process.cwd()); if (!config) { log("no config — falling through to Cursor's bash"); return; diff --git a/src/hooks/hermes/pre-tool-use.ts b/src/hooks/hermes/pre-tool-use.ts index 2deb8975..3e4b5f08 100644 --- a/src/hooks/hermes/pre-tool-use.ts +++ b/src/hooks/hermes/pre-tool-use.ts @@ -21,7 +21,7 @@ */ import { readStdin } from "../../utils/stdin.js"; -import { loadConfig } from "../../config.js"; +import { loadRoutedConfig } from "../../dir-config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; import { log as _log } from "../../utils/debug.js"; import { parseBashGrep, handleGrepDirect } from "../grep-direct.js"; @@ -67,7 +67,7 @@ async function main(): Promise { const grepParams = parseBashGrep(rewritten); if (!grepParams) return; // not grep/rg/egrep/fgrep — leave it alone - const config = loadConfig(); + const config = loadRoutedConfig(input.cwd ?? process.cwd()); if (!config) { log("no config — falling through to Hermes"); return; diff --git a/src/hooks/session-start-setup.ts b/src/hooks/session-start-setup.ts index fa2ef0f5..a71828f5 100644 --- a/src/hooks/session-start-setup.ts +++ b/src/hooks/session-start-setup.ts @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { loadCredentials, saveCredentials } from "../commands/auth.js"; -import { loadConfig } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { readStdin } from "../utils/stdin.js"; import { log as _log } from "../utils/debug.js"; @@ -54,7 +54,7 @@ async function main(): Promise { if (input.session_id) { try { - const config = loadConfig(); + const config = loadRoutedConfig(input.cwd ?? process.cwd()); if (config) { const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, config.tableName); await api.ensureTable(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c33fce5a..f1feb937 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -18,7 +18,7 @@ import * as z from "zod/v3"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { loadCredentials } from "../commands/auth.js"; -import { loadConfig } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { isMissingTableError } from "../deeplake-schema.js"; import { sqlStr, sqlLike } from "../utils/sql.js"; @@ -40,7 +40,7 @@ function getContext(): ServerContext | { error: string } { if (!creds?.token) { return { error: "Not authenticated. Run `hivemind login` to sign in to Deeplake." }; } - const config = loadConfig(); + const config = loadRoutedConfig(); if (!config) { return { error: "Hivemind config could not be loaded — credentials present but invalid." }; } diff --git a/src/skillify/auto-pull.ts b/src/skillify/auto-pull.ts index 416f6110..a0bb1e40 100644 --- a/src/skillify/auto-pull.ts +++ b/src/skillify/auto-pull.ts @@ -25,7 +25,8 @@ * exactly equivalent to `hivemind skillify pull --all-users --to global`. */ -import { loadConfig, type Config } from "../config.js"; +import { type Config } from "../config.js"; +import { loadRoutedConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { runPull, type QueryFn } from "./pull.js"; import { log as _log } from "../utils/debug.js"; @@ -80,8 +81,12 @@ export async function autoPullSkills(deps: AutoPullDeps = {}): Promise { const toolUseId = process.env[SKILLOPT_ENV.TOOL_USE_ID] || undefined; if (!sessionId || !skillRef) { log("no session/skill in env — nothing to do"); return; } - const config = loadConfig(); + const config = loadRoutedConfig(); if (!config?.token) { log("no config/credentials — exiting"); return; } const api = new DeeplakeApi(config.token, config.apiUrl, config.orgId, config.workspaceId, config.tableName); diff --git a/tests/claude-code/skillify-auto-pull.test.ts b/tests/claude-code/skillify-auto-pull.test.ts index 72405589..56c7b045 100644 --- a/tests/claude-code/skillify-auto-pull.test.ts +++ b/tests/claude-code/skillify-auto-pull.test.ts @@ -11,7 +11,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -272,3 +272,29 @@ describe("autoPullSkills — no-queryFn path (uses DeeplakeApi)", () => { expect(apiQueryMock).not.toHaveBeenCalled(); // never got past discovery }); }); + +describe("autoPullSkills — default config path routes through .hivemind", () => { + afterEach(() => { + delete process.env.HIVEMIND_TOKEN; + delete process.env.HIVEMIND_ORG_ID; + delete process.env.HIVEMIND_WORKSPACE_ID; + }); + + it("with no injected loadConfigFn, resolves the routed config from cwd (loadRoutedConfig)", async () => { + // Exercises the real default: loadRoutedConfig(deps.cwd). Env-based creds + // (fake HOME means no credentials.json) keep it offline; the injected + // queryFn stands in for the network. A `.hivemind` in cwd proves the + // default path routes rather than falling back to the global workspace. + process.env.HIVEMIND_TOKEN = "env-tok"; + process.env.HIVEMIND_ORG_ID = "env-org"; + writeFileSync(join(tmpHome, ".hivemind"), JSON.stringify({ workspaceId: "routed-ws" })); + const { fn: queryFn, calls } = makeMockQuery([sampleRow()]); + + const result = await autoPullSkills({ queryFn, install: "project", cwd: tmpHome }); + + // Not the "not-logged-in" skip — the default loadRoutedConfig produced a + // config, so the pull actually ran against the injected query. + expect(result.reason).not.toBe("not-logged-in"); + expect(calls.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/claude-code/skillify-dir-routing.test.ts b/tests/claude-code/skillify-dir-routing.test.ts new file mode 100644 index 00000000..bc2acc69 --- /dev/null +++ b/tests/claude-code/skillify-dir-routing.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * End-to-end proof that `loadRoutedConfig` (the single source of truth in + * src/dir-config.ts) actually routes through `.hivemind` — exercised via + * `hivemind skillify push`/`pull`, the writer DevOps caught landing in the + * GLOBAL workspace. `loadConfig` is mocked; `resolveDirConfig` runs for real + * against a `.hivemind` on disk. The assertion is WHICH workspace the + * DeeplakeApi was constructed against — that URL path is where the write lands + * (deeplake-api.ts: /workspaces/{workspaceId}/tables/query). + */ + +const h = vi.hoisted(() => ({ + ctorWorkspaces: [] as string[], + loadConfigMock: vi.fn(), +})); + +// loadRoutedConfig (dir-config.ts) calls this loadConfig internally, so mocking +// it here drives the whole routing helper without a network or creds file. +vi.mock("../../src/config.js", () => ({ loadConfig: h.loadConfigMock })); +vi.mock("../../src/deeplake-api.js", () => ({ + DeeplakeApi: class { + constructor(_token: string, _apiUrl: string, _orgId: string, workspaceId: string) { + h.ctorWorkspaces.push(workspaceId); + } + async query() { return []; } + }, +})); + +import { runSkillifyCommand } from "../../src/commands/skillify.js"; + +const GLOBAL_CONFIG = { + token: "tok", apiUrl: "https://api.test", orgId: "org", workspaceId: "global-ws", + userName: "tester", skillsTableName: "skills", codebaseTableName: "codebase", + tableName: "memory", sessionsTableName: "sessions", memoryPath: "/m", orgName: "org", +}; + +let root: string; +let originalCwd: string; + +function hivemind(dir: string, body: unknown): void { + writeFileSync(join(dir, ".hivemind"), JSON.stringify(body)); +} + +function writeSkill(dir: string, name: string): void { + const skillDir = join(dir, ".claude", "skills", name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + ["---", `name: ${name}`, "description: a test skill", "version: 1", "author: tester", "---", "", "body", ""].join("\n"), + ); +} + +beforeEach(() => { + originalCwd = process.cwd(); + root = mkdtempSync(join(tmpdir(), "hivemind-skillify-route-")); + h.ctorWorkspaces.length = 0; + h.loadConfigMock.mockReset().mockReturnValue(GLOBAL_CONFIG); + delete process.env.HIVEMIND_ORG_ID; + delete process.env.HIVEMIND_WORKSPACE_ID; + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + process.chdir(originalCwd); + rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("skillify push routes through .hivemind (loadRoutedConfig)", () => { + it("pushes to the directory's routed workspace, not the global one", async () => { + hivemind(root, { workspaceId: "workspace2" }); + writeSkill(root, "my-skill"); + process.chdir(root); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("workspace2"); + expect(h.ctorWorkspaces).not.toContain("global-ws"); + }); + + it("inherits the workspace from an ancestor .hivemind", async () => { + hivemind(root, { workspaceId: "client-work" }); + const leaf = join(root, "svc", "deep"); + mkdirSync(leaf, { recursive: true }); + writeSkill(leaf, "my-skill"); + process.chdir(leaf); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("client-work"); + }); + + it("still routes push under collect:false — collect gates capture, not identity", async () => { + hivemind(root, { workspaceId: "client-work", collect: false }); + writeSkill(root, "my-skill"); + process.chdir(root); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("client-work"); + }); + + it("falls back to the global workspace when no .hivemind applies", async () => { + writeSkill(root, "my-skill"); + process.chdir(root); + await runSkillifyCommand(["push", "my-skill", "--from", "project"]); + expect(h.ctorWorkspaces).toContain("global-ws"); + }); +}); + +describe("skillify pull routes through .hivemind (loadRoutedConfig)", () => { + it("pulls from the directory's routed workspace", async () => { + hivemind(root, { workspaceId: "workspace2" }); + process.chdir(root); + await runSkillifyCommand(["pull"]); + expect(h.ctorWorkspaces).toContain("workspace2"); + expect(h.ctorWorkspaces).not.toContain("global-ws"); + }); + + it("falls back to the global workspace with no .hivemind", async () => { + process.chdir(root); + await runSkillifyCommand(["pull"]); + expect(h.ctorWorkspaces).toContain("global-ws"); + }); +}); diff --git a/tests/shared/dir-config-single-source.test.ts b/tests/shared/dir-config-single-source.test.ts new file mode 100644 index 00000000..01feed6e --- /dev/null +++ b/tests/shared/dir-config-single-source.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * SINGLE-SOURCE-OF-TRUTH GUARD for per-directory workspace routing. + * + * `.hivemind` routing broke repeatedly because it was wired writer-by-writer: + * each new code path that built a DeeplakeApi from a raw `loadConfig()` silently + * wrote/read the GLOBAL workspace instead of the directory's routed one (the + * skillify/goals/rules/other-agent bugs). This test makes that class of + * regression impossible to merge. + * + * The invariant: every module that constructs a `DeeplakeApi` MUST obtain its + * config through a router — `loadRoutedConfig` (the single entry point) or + * `resolveDirConfig`/`resolveCaptureConfig` (when it also needs the `collect` + * flag) — UNLESS it is explicitly allow-listed below with a reason. + * + * Adding a new DeeplakeApi call site therefore forces a choice: route it, or + * justify why it doesn't (account-level op, creds-based, no directory context). + * A silent unrouted writer can no longer slip in. + */ + +const __dir = fileURLToPath(new URL(".", import.meta.url)); +const SRC = join(__dir, "..", "..", "src"); + +/** + * Files that build a DeeplakeApi but intentionally do NOT route through + * `.hivemind`. Each MUST carry a reason — this list is the audit trail. + */ +const ALLOWLIST: Record = { + "commands/session-prune.ts": + "Account-level cleanup of the user's own sessions; not scoped to a directory's workspace.", + "commands/docs.ts": + "Docs use a separate per-(org,repo) consent + project-key model, not .hivemind workspace routing.", + "mcp/cowork-ingest.ts": + "Claude Cowork (desktop) has no directory context — a fixed COWORK_PROJECT, nothing to route on.", + "notifications/sources/resume-brief.ts": + "Display-only read built from creds.workspaceId; routing it means threading a resolved workspace in — tracked follow-up, not a silent writer.", + "notifications/sources/open-goals.ts": + "Display-only read (banner open-goals) built from the passed-in creds, not loadConfig — same follow-up as resume-brief.", +}; + +const ROUTER_TOKENS = ["loadRoutedConfig", "resolveDirConfig", "resolveCaptureConfig"]; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + const st = statSync(p); + if (st.isDirectory()) walk(p, out); + else if (name.endsWith(".ts") && !name.endsWith(".d.ts")) out.push(p); + } + return out; +} + +/** Repo-relative (posix) path under src/, e.g. "commands/goal.ts". */ +function rel(abs: string): string { + return abs.slice(SRC.length + 1).split("\\").join("/"); +} + +describe("dir-config single source of truth", () => { + const files = walk(SRC); + const apiSites = files.filter((f) => readFileSync(f, "utf-8").includes("new DeeplakeApi(")); + + it("finds DeeplakeApi construction sites to guard (sanity)", () => { + // If this ever hits 0 the glob/walk broke and the guard is silently vacuous. + expect(apiSites.length).toBeGreaterThan(10); + }); + + it("every DeeplakeApi site routes through .hivemind or is allow-listed with a reason", () => { + const offenders: string[] = []; + for (const abs of apiSites) { + const key = rel(abs); + const src = readFileSync(abs, "utf-8"); + const routes = ROUTER_TOKENS.some((t) => src.includes(t)); + const allowed = key in ALLOWLIST; + if (!routes && !allowed) { + offenders.push( + `${key}: builds a DeeplakeApi from an unrouted config. ` + + `Use loadRoutedConfig() (src/dir-config.ts), or add an ALLOWLIST entry with a reason.`, + ); + } + } + expect(offenders, offenders.join("\n")).toEqual([]); + }); + + it("the allow-list has no stale entries (each still builds a DeeplakeApi and still does not route)", () => { + const stale: string[] = []; + for (const key of Object.keys(ALLOWLIST)) { + const abs = join(SRC, key); + let src: string; + try { + src = readFileSync(abs, "utf-8"); + } catch { + stale.push(`${key}: allow-listed but no longer exists — remove it.`); + continue; + } + if (!src.includes("new DeeplakeApi(")) { + stale.push(`${key}: allow-listed but no longer builds a DeeplakeApi — remove it.`); + } else if (ROUTER_TOKENS.some((t) => src.includes(t))) { + stale.push(`${key}: now routes — remove it from the allow-list so the guard covers it.`); + } + } + expect(stale, stale.join("\n")).toEqual([]); + }); + + it("every allow-list entry carries a non-empty reason", () => { + for (const [key, reason] of Object.entries(ALLOWLIST)) { + expect(reason.trim().length, `${key} needs a reason`).toBeGreaterThan(10); + } + }); +});