Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions harnesses/claude-code/skills/hivemind-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ Hivemind can mine reusable skills from agent session logs and share them across
- `hivemind skillify unpull --user <email>` — remove only that author's pulls
- `hivemind skillify unpull --not-mine` — remove all pulls except your own
- `hivemind skillify unpull --dry-run` — preview without touching disk
- `hivemind skillify dedupe` — remove pulled <name>--<you> copies of your own skills
- `hivemind skillify dedupe --dry-run` — preview without touching disk
- `hivemind skillify scope <me|team>` — sharing scope for newly mined skills
- `hivemind skillify install <project|global>` — default install location for new skills
- `hivemind skillify promote <skill-name>` — move a project skill to the global location
Expand Down
2 changes: 2 additions & 0 deletions harnesses/codex/skills/deeplake-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Hivemind can mine reusable skills from agent session logs and share them across
- `hivemind skillify unpull --user <email>` — remove only that author's pulls
- `hivemind skillify unpull --not-mine` — remove all pulls except your own
- `hivemind skillify unpull --dry-run` — preview without touching disk
- `hivemind skillify dedupe` — remove pulled <name>--<you> copies of your own skills
- `hivemind skillify dedupe --dry-run` — preview without touching disk
- `hivemind skillify scope <me|team>` — sharing scope for newly mined skills
- `hivemind skillify install <project|global>` — default install location for new skills
- `hivemind skillify promote <skill-name>` — move a project skill to the global location
Expand Down
2 changes: 2 additions & 0 deletions harnesses/pi/extension-source/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,8 @@ const PI_SKILLIFY_COMMANDS: { cmd: string; desc: string }[] = [
{ cmd: "hivemind skillify unpull --user <email>", desc: "remove only that author's pulls" },
{ cmd: "hivemind skillify unpull --not-mine", desc: "remove all pulls except your own" },
{ cmd: "hivemind skillify unpull --dry-run", desc: "preview without touching disk" },
{ cmd: "hivemind skillify dedupe", desc: "remove pulled <name>--<you> copies of your own skills" },
{ cmd: "hivemind skillify dedupe --dry-run", desc: "preview without touching disk" },
{ cmd: "hivemind skillify scope <me|team|org>", desc: "sharing scope for newly mined skills" },
{ cmd: "hivemind skillify install <project|global>", desc: "default install location for new skills" },
{ cmd: "hivemind skillify promote <skill-name>", desc: "move a project skill to the global location" },
Expand Down
9 changes: 9 additions & 0 deletions src/cli/skillify-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const SKILLIFY_COMMANDS: SkillifyCommand[] = [
{ cmd: "hivemind skillify unpull --user <email>", desc: "remove only that author's pulls" },
{ cmd: "hivemind skillify unpull --not-mine", desc: "remove all pulls except your own" },
{ cmd: "hivemind skillify unpull --dry-run", desc: "preview without touching disk" },
{ cmd: "hivemind skillify dedupe", desc: "remove pulled <name>--<you> copies of your own skills" },
{ cmd: "hivemind skillify dedupe --dry-run", desc: "preview without touching disk" },
{ cmd: "hivemind skillify scope <me|team|org>", desc: "sharing scope for newly mined skills" },
{ cmd: "hivemind skillify install <project|global>", desc: "default install location for new skills" },
{ cmd: "hivemind skillify promote <skill-name>", desc: "move a project skill to the global location" },
Expand Down Expand Up @@ -126,6 +128,13 @@ export const SKILLIFY_SPEC: SkillifySubcommand[] = [
{ flag: "--dry-run", desc: "preview without touching disk" },
],
},
{
cmd: "hivemind skillify dedupe",
desc: "remove pulled <name>--<you> copies of your own skills",
options: [
{ flag: "--dry-run", desc: "preview without touching disk" },
],
},
{
cmd: "hivemind skillify scope",
args: "<me|team|org>",
Expand Down
27 changes: 27 additions & 0 deletions src/commands/skillify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { runPull, type PullSummary } from "../skillify/pull.js";
import { describeAgentSkillsRoots } from "../skillify/agent-roots.js";
import { runPush } from "../skillify/push.js";
import { runUnpull } from "../skillify/unpull.js";
import { runDedupe } from "../skillify/dedupe.js";
import { loadConfig } from "../config.js";
import { loadRoutedConfig } from "../dir-config.js";
import { DeeplakeApi } from "../deeplake-api.js";
Expand Down Expand Up @@ -241,6 +242,8 @@ async function pullSkills(args: string[]): Promise<void> {
tableName: config.skillsTableName,
install: toRaw,
cwd: toRaw === "project" ? process.cwd() : undefined,
projectCwd: process.cwd(),
selfAuthor: config.userName,
users,
skillName,
dryRun,
Expand Down Expand Up @@ -409,6 +412,29 @@ async function unpullSkills(args: string[]): Promise<void> {
console.log(`Result: ${summary.removed} removed, ${summary.wouldRemove} dry-run, ${summary.kept} kept${prunedNote}.`);
}

function dedupeSkills(args: string[]): void {
const dryRun = takeBooleanFlag([...args], "--dry-run");
const config = loadConfig();
if (!config) {
console.error("dedupe needs a logged-in user to know which skills are yours. Run: hivemind login");
process.exit(1);
}
const entries = runDedupe({ myUsername: config.userName, cwd: process.cwd(), dryRun });
console.log(`Own skills: ${config.userName}${dryRun ? " · dry-run" : ""}`);
const verb = { removed: "remove", moved: "move", replaced: "replace" } as const;
for (const e of entries) {
const tag = e.action === "kept" ? "· kept"
: e.action === "failed" ? "✗ failed"
: dryRun ? `→ would ${verb[e.action]}` : `✓ ${e.action}`;
console.log(` ${tag.padEnd(18)} ${e.dirName.padEnd(50)} (${e.reason})`);
}
const kept = entries.filter(e => e.action === "kept").length;
const failed = entries.filter(e => e.action === "failed").length;
const changed = entries.length - kept - failed;
console.log(`Result: ${changed} ${dryRun ? "to fix" : "fixed"}, ${kept} kept${failed ? `, ${failed} failed` : ""}.`);
if (failed) process.exit(1);
}

export function runSkillifyCommand(args: string[]): void {
const sub = args[0];
if (!sub || sub === "status") { showStatus(); return; }
Expand All @@ -433,6 +459,7 @@ export function runSkillifyCommand(args: string[]): void {
.catch(() => { /* test-only safety net */ });
return;
}
if (sub === "dedupe") { dedupeSkills(args.slice(1)); return; }
if (sub === "unpull") {
unpullSkills(args.slice(1))
.catch(e => {
Expand Down
2 changes: 2 additions & 0 deletions src/skillify/auto-pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ export async function autoPullSkills(deps: AutoPullDeps = {}): Promise<AutoPullR
tableName: config.skillsTableName,
install,
cwd: install === "project" ? (deps.cwd ?? process.cwd()) : undefined,
projectCwd: deps.cwd ?? process.cwd(),
selfAuthor: config.userName,
users: [],
dryRun: false,
force: false,
Expand Down
124 changes: 124 additions & 0 deletions src/skillify/dedupe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Remove the duplicate copies of the user's OWN skills that older pulls
* installed as `<name>--<me>` beside the locally-mined `<name>`.
*
* Before pull wrote own rows into the `<name>` slot, every own skill pulled
* back from the org table landed as `<name>--<me>`. With a locally-mined
* `<name>` (same machine, another repo, or a second machine) the user saw
* two or more copies of one skill. Pull now keeps one dir per own skill;
* this command repairs installs made before that.
*
* Only pull-managed entries (in `pulled.json`) are candidates. Per entry:
* - a local `<name>` at the same or a higher version exists → remove the copy
* - no local `<name>` in the install root → move the copy into the `<name>` slot
* - the install root's `<name>` is older → replace its SKILL.md (old one kept
* as SKILL.md.bak), then remove the copy
* - only an older project `<name>` exists → keep the copy (project files are
* the repo's; the user decides)
*/

import { copyFileSync, existsSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { loadManifest, recordPull, removePullEntry, unlinkSymlinks, type PulledEntry } from "./manifest.js";
import { detectAgentSkillsRoots } from "./agent-roots.js";
import { fanOutSymlinks, readLocalVersion, resolvePullDestination } from "./pull.js";

export interface DedupeOptions {
myUsername: string;
/** Project whose `.claude/skills/<name>` also counts as a local copy. */
cwd: string;
dryRun?: boolean;
}

export type DedupeAction = "removed" | "moved" | "replaced" | "kept" | "failed";

export interface DedupeEntry {
dirName: string;
name: string;
path: string;
action: DedupeAction;
dryRun: boolean;
reason: string;
}

function isOwnCopy(e: PulledEntry, me: string): boolean {
return e.author === me && e.dirName !== e.name && e.dirName.endsWith(`--${me}`);
}

function removeCopy(e: PulledEntry): void {
rmSync(join(e.installRoot, e.dirName), { recursive: true, force: true });
unlinkSymlinks(e.symlinks);
removePullEntry(e.install, e.installRoot, e.dirName);
}

function moveCopy(e: PulledEntry): void {
const slot = join(e.installRoot, e.name);
renameSync(join(e.installRoot, e.dirName), slot);
unlinkSymlinks(e.symlinks);
removePullEntry(e.install, e.installRoot, e.dirName);
const symlinks = e.install === "global" ? fanOutSymlinks(slot, e.name, detectAgentSkillsRoots(e.installRoot)) : [];
recordPull({ ...e, dirName: e.name, symlinks });
}

function replaceSlot(e: PulledEntry): void {
const slotFile = join(e.installRoot, e.name, "SKILL.md");
copyFileSync(slotFile, `${slotFile}.bak`);
copyFileSync(join(e.installRoot, e.dirName, "SKILL.md"), slotFile);
removeCopy(e);
}

function decide(e: PulledEntry, cwd: string): { action: DedupeAction; reason: string } {
const copyVersion = readLocalVersion(join(e.installRoot, e.dirName, "SKILL.md")) ?? e.remoteVersion;
const slotVersion = readLocalVersion(join(e.installRoot, e.name, "SKILL.md"));
const projectVersion = e.install === "global"
? readLocalVersion(join(resolvePullDestination("project", cwd), e.name, "SKILL.md"))
: null;
const localMax = Math.max(slotVersion ?? -1, projectVersion ?? -1);
if (localMax >= copyVersion) {
return { action: "removed", reason: `local ${e.name} v${localMax} >= copy v${copyVersion}` };
}
const slotExists = existsSync(join(e.installRoot, e.name));
if (slotExists && slotVersion === null) {
return { action: "kept", reason: `${e.name} exists without a version; resolve by hand` };
}
if (!slotExists && projectVersion === null) {
return { action: "moved", reason: `no local ${e.name}; copy v${copyVersion} becomes ${e.name}` };
}
if (slotVersion !== null) {
return { action: "replaced", reason: `local ${e.name} v${slotVersion} < copy v${copyVersion}` };
}
return { action: "kept", reason: `project ${e.name} v${projectVersion} < copy v${copyVersion}; update the project copy by hand` };
}

const APPLY: Record<Exclude<DedupeAction, "kept" | "failed">, (e: PulledEntry) => void> = {
removed: removeCopy,
moved: moveCopy,
replaced: replaceSlot,
};

function apply(
e: PulledEntry,
decision: { action: DedupeAction; reason: string },
dryRun: boolean,
): { action: DedupeAction; reason: string } {
if (dryRun || decision.action === "kept" || decision.action === "failed") return decision;
try {
APPLY[decision.action](e);
return decision;
} catch (err: any) {
return { action: "failed", reason: `${decision.action}: ${err?.message ?? err}` };
}
}

export function runDedupe(opts: DedupeOptions): DedupeEntry[] {
const dryRun = opts.dryRun ?? false;
const out: DedupeEntry[] = [];
for (const e of loadManifest().entries) {
if (!isOwnCopy(e, opts.myUsername)) continue;
const path = join(e.installRoot, e.dirName);
if (!existsSync(path)) continue;
const decision = decide(e, opts.cwd);
out.push({ dirName: e.dirName, name: e.name, path, dryRun, ...apply(e, decision, dryRun) });
}
return out;
}
86 changes: 48 additions & 38 deletions src/skillify/existing-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
import { listSkills, resolveSkillsRoot, parseFrontmatter } from "./skill-writer.js";

export interface TaggedSkill {
/** Name the gate sees and answers with. */
name: string;
/** Directory under the source root. Differs from `name` for a pulled `<name>--<me>` copy. */
dirName: string;
body: string;
source: "project" | "global";
/**
Expand All @@ -33,83 +36,91 @@ export interface TaggedSkill {
* legacy local file isn't accidentally treated as cross-author.
*/
author?: string;
version: number;
}

export interface MergeTarget {
install: "project" | "global";
skillsRoot: string;
dirName: string;
}

export interface ExistingSkillsBlock {
/** Names eligible as MERGE targets. Empty when no skills exist. */
mergeTargetNames: string[];
/** Where each MERGE-eligible name lives on disk. */
mergeTargets: Record<string, MergeTarget>;
/** Rendered block of all skills (project + global) for the gate prompt. */
block: string;
}

/**
* Collect every existing skill the gate should know about, with its
* source root + author tagged. If a name collides across roots, the
* project copy wins (the user is presumed to be actively editing it
* locally).
* source root + author tagged.
*
* `userName` lets the user's own pulled global copy (`<name>--<me>`)
* answer to its real `<name>`, so the gate continues that version chain
* instead of starting a new v1 under the same name.
*
* Name collisions keep the higher version (a version only grows by a
* merge, so higher = newer); a tie keeps the project copy, which the
* user is presumed to be editing locally.
*/
export function listAllExistingSkills(cwd: string): TaggedSkill[] {
export function listAllExistingSkills(cwd: string, userName?: string): TaggedSkill[] {
const projectRoot = resolveSkillsRoot("project", cwd);
const globalRoot = resolveSkillsRoot("global", cwd);
const tag = (source: "project" | "global") => (s: { name: string; body: string }): TaggedSkill => {
const parsed = parseFrontmatter(s.body);
const author = typeof parsed?.fm.author === "string" && parsed.fm.author.length > 0
? parsed.fm.author
: undefined;
return { name: s.name, body: s.body, source, author };
const version = typeof parsed?.fm.version === "number" ? parsed.fm.version : 1;
const fmName = typeof parsed?.fm.name === "string" ? parsed.fm.name : "";
const ownPulledCopy = source === "global" && !!userName && author === userName
&& s.name === `${fmName}--${userName}`;
return { name: ownPulledCopy ? fmName : s.name, dirName: s.name, body: s.body, source, author, version };
};
const tagged: TaggedSkill[] = [
...listSkills(projectRoot).map(tag("project")),
...listSkills(globalRoot).map(tag("global")),
];
const seen = new Set<string>();
const out: TaggedSkill[] = [];
const byName = new Map<string, TaggedSkill>();
for (const s of tagged) {
if (seen.has(s.name)) continue;
seen.add(s.name);
out.push(s);
const kept = byName.get(s.name);
if (!kept || s.version > kept.version) byName.set(s.name, s);
}
return out;
return [...byName.values()];
}

/**
* Render the gate-prompt block. `charCap` is the soft budget — once we
* cross it, we emit a "[…N more omitted]" line and stop.
*
* MERGE eligibility (post-#125 review): only `[project]` skills are
* returned in `mergeTargetNames`. A previous iteration of this PR opened
* MERGE to every rendered skill (including `[global]`), but the worker's
* mergeSkill path is rooted at `cfg.install` — for a `[global]` target
* the local file doesn't exist under that root, so mergeSkill throws and
* the fallback writes a brand-new skill (creating exactly the duplicate
* we set out to prevent). Until the worker carries the source root
* through the verdict and resolves a `<root>/<name>--<author>` dirname
* for global targets, `[global]` skills stay reference-only here.
* Tracked as a follow-up (see PR #125 description).
* MERGE eligibility: `[project]` skills, and `[global]` skills authored by
* `userName`. A global skill from a teammate stays `[global, read-only]`:
* the worker writes MERGEs into the user's own skills only, and a
* teammate's chain continues on the teammate's machine.
*
* Two narrower issues caught in the same review and fixed here:
* - `mergeTargetNames` must only include skills whose bodies were
* actually rendered into the prompt — otherwise the truncation tail
* leaks names the LLM never saw the context for.
* - The `[global, read-only]` tag is restored so the gate prompt's
* "MERGE only on [project]" rule has a stable surface to point at.
* `mergeTargetNames` only includes skills whose bodies were actually
* rendered into the prompt — otherwise the truncation tail leaks names
* the LLM never saw the context for.
*/
export function renderExistingSkillsBlock(cwd: string, charCap: number): ExistingSkillsBlock {
const skills = listAllExistingSkills(cwd);
export function renderExistingSkillsBlock(cwd: string, charCap: number, userName?: string): ExistingSkillsBlock {
const skills = listAllExistingSkills(cwd, userName);
if (skills.length === 0) {
return {
mergeTargetNames: [],
mergeTargets: {},
block: "(no existing skills — MERGE is NOT a valid choice; pick KEEP or SKIP only)",
};
}
let total = 0;
const out: string[] = [];
const mergeTargetNames: string[] = [];
const mergeTargets: Record<string, MergeTarget> = {};
for (const s of skills) {
// Tag carries both the install root and the author so the gate
// prompt can communicate "this one's yours, MERGE-eligible; this
// one's a teammate's pulled into your global root, reference only".
const sourceTag = s.source === "project" ? "project" : "global, read-only";
const mergeable = s.source === "project" || (!!userName && s.author === userName);
const sourceTag = mergeable ? s.source : "global, read-only";
const authorTag = s.author ? `, author=${s.author}` : "";
const block = `--- existing skill [${sourceTag}${authorTag}]: ${s.name} ---\n${s.body}\n`;
if (total + block.length > charCap) {
Expand All @@ -118,10 +129,9 @@ export function renderExistingSkillsBlock(cwd: string, charCap: number): Existin
}
out.push(block);
total += block.length;
// Only register the name once we know the block fit and the source
// is [project] — global skills are reference-only per the policy
// documented above.
if (s.source === "project") mergeTargetNames.push(s.name);
if (!mergeable) continue;
mergeTargetNames.push(s.name);
mergeTargets[s.name] = { install: s.source, skillsRoot: resolveSkillsRoot(s.source, cwd), dirName: s.dirName };
}
return { mergeTargetNames, block: out.join("\n") };
return { mergeTargetNames, mergeTargets, block: out.join("\n") };
}
Loading
Loading