From 60bb48950ab6c4c23d12e1302d226a58ff199dd6 Mon Sep 17 00:00:00 2001 From: khustup2 Date: Fri, 17 Jul 2026 20:28:04 +0000 Subject: [PATCH] fix(skillify): route push/pull through .hivemind, not the global workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hivemind skillify push` read `loadConfig()` raw and passed the global `workspaceId` straight to the API, so a skill saved from a directory routed by `.hivemind` landed in the GLOBAL workspace — the exact symptom DevOps reported ("saved a local skill, it showed up in the default workspace"). Session capture already routes via resolveDirConfig; skillify was a straggler that never got threaded. Resolve the nearest `.hivemind` at both the push and pull sites so skills write to, and read from, the directory's routed workspace — symmetric with where that tree's traces are captured. org/workspace are identity and apply to reads and writes alike; `collect:false` gates capture only, so a routed+opted-out directory still pushes/pulls to its workspace. unpull is untouched: it is a local-filesystem operation that never queries a workspace (it only reads userName for --not-mine). The DeeplakeApi ctor puts the workspace in the URL path (/workspaces/{workspaceId}/tables/query), so that arg is where the write lands; the new test asserts on it with resolveDirConfig running for real. Verified to fail against the pre-fix code. --- src/commands/skillify.ts | 18 ++- .../claude-code/skillify-dir-routing.test.ts | 125 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tests/claude-code/skillify-dir-routing.test.ts diff --git a/src/commands/skillify.ts b/src/commands/skillify.ts index 8393d861..afb2c0a6 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 { resolveDirConfig } from "../dir-config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { runMineLocal } from "./mine-local.js"; import { renderSubcommandUsageBlock } from "../cli/skillify-spec.js"; @@ -221,11 +222,14 @@ 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(); - if (!config) { + const base = loadConfig(); + if (!base) { console.error("Not logged in. Run: hivemind login"); process.exit(1); } + // Pull from the directory's routed workspace, symmetric with push and with + // where this tree's traces are captured (see src/dir-config.ts). + const config = resolveDirConfig(base, process.cwd()).config; const api = new DeeplakeApi( config.token, config.apiUrl, config.orgId, config.workspaceId, config.skillsTableName, ); @@ -285,10 +289,16 @@ async function pushSkills(args: string[]): Promise { throw new Error("Usage: hivemind skillify push [--from project|global] [--dry-run]"); } - const config = loadConfig(); - if (!config) { + const base = loadConfig(); + if (!base) { throw new Error("Not logged in. Run: hivemind login"); } + // Honor the nearest `.hivemind` so a push lands in the directory's routed + // workspace, not the global one — matching where this tree's session traces + // are captured. Without this, `skillify push` always wrote to the global + // workspace regardless of `.hivemind` (the org/workspace fields are identity; + // they apply to writes and reads alike — see src/dir-config.ts). + const config = resolveDirConfig(base, process.cwd()).config; const api = new DeeplakeApi( config.token, config.apiUrl, config.orgId, config.workspaceId, config.skillsTableName, ); 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..0a9301fa --- /dev/null +++ b/tests/claude-code/skillify-dir-routing.test.ts @@ -0,0 +1,125 @@ +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"; + +/** + * `hivemind skillify push`/`pull` must route to the directory's `.hivemind` + * workspace, exactly like session capture does — otherwise a skill saved from a + * routed repo lands in the GLOBAL workspace (the bug DevOps hit: "saved a local + * skill, it appeared in the default workspace"). skillify.ts used `loadConfig()` + * raw with no `.hivemind` overlay; these tests pin the fix by asserting WHICH + * workspace the DeeplakeApi was constructed against — that URL path is where the + * write lands (deeplake-api.ts: /workspaces/{workspaceId}/tables/query). + * + * resolveDirConfig runs for REAL against the process cwd; only the config + * boundary and the API transport are mocked. + */ + +const h = vi.hoisted(() => ({ + ctorWorkspaces: [] as string[], + loadConfigMock: vi.fn(), +})); + +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", () => { + 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", () => { + 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"); + }); +});