Ctrl K
ComunidadGuías…

lib/mates.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
var resolveMateIdentityFile = require('./mate-identity-file').resolveMateIdentityFile;
var fs = require("fs");
var path = require("path");
var crypto = require("crypto");
var config = require("./config");
var workspaceColor = require("./mate-workspace-color");
var avatarCatalog = require("./mate-avatar-catalog");
var avatarMigration = require("./mate-avatar-migration");

var crisisSafety = require("./crisis-safety");
var matesPrompts = require("./mates-prompts");
var matesIdentity = require("./mates-identity");
var { attachKnowledge } = require("./mates-knowledge");

// --- Path resolution ---

function resolveMatesRoot(ctx) {
  // OS-users mode: per-linuxUser home directory
  if (ctx && ctx.linuxUser) {
    return path.join("/home", ctx.linuxUser, ".clay", "mates");
  }
  // Multi-user mode: per-userId subdirectory
  if (ctx && ctx.multiUser && ctx.userId) {
    return path.join(config.CONFIG_DIR, "mates", ctx.userId);
  }
  // Single-user mode: flat directory
  return path.join(config.CONFIG_DIR, "mates");
}

// --- Wiring: knowledge module needs resolveMatesRoot ---
var knowledge = attachKnowledge(resolveMatesRoot);
var loadCommonKnowledge = knowledge.loadCommonKnowledge;
var saveCommonKnowledge = knowledge.saveCommonKnowledge;
var promoteKnowledge = knowledge.promoteKnowledge;
var depromoteKnowledge = knowledge.depromoteKnowledge;
var getCommonKnowledgeForMate = knowledge.getCommonKnowledgeForMate;
var readCommonKnowledgeFile = knowledge.readCommonKnowledgeFile;
var isPromoted = knowledge.isPromoted;

// --- Aliases from extracted modules ---
var TEAM_MARKER = matesPrompts.TEAM_MARKER;
var TEAM_SECTION = matesPrompts.TEAM_SECTION;
var SESSION_MEMORY_MARKER = matesPrompts.SESSION_MEMORY_MARKER;
var SESSION_MEMORY_SECTION = matesPrompts.SESSION_MEMORY_SECTION;
var STICKY_NOTES_MARKER = matesPrompts.STICKY_NOTES_MARKER;
var STICKY_NOTES_SECTION = matesPrompts.STICKY_NOTES_SECTION;
var PROJECT_REGISTRY_MARKER = matesPrompts.PROJECT_REGISTRY_MARKER;
var DEBATE_AWARENESS_MARKER = matesPrompts.DEBATE_AWARENESS_MARKER;
var DEBATE_AWARENESS_SECTION = matesPrompts.DEBATE_AWARENESS_SECTION;
var ALL_SYSTEM_MARKERS = matesPrompts.ALL_SYSTEM_MARKERS;
var buildTeamSection = matesPrompts.buildTeamSection;
var enforceTeamAwareness = matesPrompts.enforceTeamAwareness;
var enforceProjectRegistry = matesPrompts.enforceProjectRegistry;
var buildProjectRegistrySection = matesPrompts.buildProjectRegistrySection;
var enforceSessionMemory = matesPrompts.enforceSessionMemory;
var enforceStickyNotes = matesPrompts.enforceStickyNotes;
var enforceDebateAwareness = matesPrompts.enforceDebateAwareness;
var PRIMARY_CAPABILITIES_MARKER = matesIdentity.PRIMARY_CAPABILITIES_MARKER;
var IDENTITY_MIN_LENGTH = matesIdentity.IDENTITY_MIN_LENGTH;
var buildPrimaryCapabilitiesSection = matesIdentity.buildPrimaryCapabilitiesSection;
var backupIdentity = matesIdentity.backupIdentity;
var loadIdentityBackup = matesIdentity.loadIdentityBackup;
var logIdentityChange = matesIdentity.logIdentityChange;
var extractIdentity = function (content) { return matesIdentity.extractIdentity(content, ALL_SYSTEM_MARKERS); };

function buildMateCtx(userId) {
  if (!userId) return { userId: null, multiUser: false, linuxUser: null };
  // Lazy require to avoid circular dependency
  var users = require("./users");
  var multiUser = users.isMultiUser();
  var linuxUser = null;
  if (multiUser && userId) {
    var user = users.findUserById(userId);
    if (user && user.linuxUser) {
      linuxUser = user.linuxUser;
    }
  }
  return { userId: userId, multiUser: multiUser, linuxUser: linuxUser };
}

function isMateIdFormat(id) {
  if (!id) return false;
  return typeof id === "string" && id.indexOf("mate_") === 0;
}

// --- Default data ---

function defaultData() {
  return { mates: [] };
}

// --- Load / Save ---

function matesFilePath(ctx) {
  return path.join(resolveMatesRoot(ctx), "mates.json");
}

function loadMates(ctx) {
  try {
    var raw = fs.readFileSync(matesFilePath(ctx), "utf8");
    var data = JSON.parse(raw);
    if (!data.mates) data.mates = [];
  } catch (e) {
    return defaultData();
  }
  // Migration write failures must propagate, never masquerade as an empty registry.
  if (avatarMigration.migrate(data)) saveMates(ctx, data);
  return data;
}

function saveMates(ctx, data) {
  var filePath = matesFilePath(ctx);
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  var tmpPath = filePath + ".tmp";
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
  fs.renameSync(tmpPath, filePath);
}

// --- CRUD ---

function generateMateId() {
  return "mate_" + crypto.randomUUID();
}

function createMate(ctx, seedData) {
  var data = loadMates(ctx);
  var id = generateMateId();
  var userId = ctx ? ctx.userId : null;

  var mate = {
    id: id,
    name: null,
    createdBy: userId,
    createdAt: Date.now(),
    seedData: seedData || {},
    vendor: (seedData && seedData.vendor) || "claude",
    model: (seedData && seedData.model) || null,
    profile: {
      displayName: null,
      avatarColor: "#5857fc",
      avatarStyle: "imprint",
      avatarCatalogId: "B" + String(crypto.randomInt(1, 49)).padStart(2, "0"),
      avatarSeed: crypto.randomBytes(4).toString("hex"),
    },
    bio: null,
    status: "interviewing",
    interviewProjectPath: null,
  };

  avatarMigration.normalizeMate(mate);
  data.mates.push(mate);
  saveMates(ctx, data);

  // Create the mate's identity directory
  var mateDir = getMateDir(ctx, id);
  fs.mkdirSync(mateDir, { recursive: true });

  // Write initial mate.yaml
  var yaml = "# Mate metadata\n";
  yaml += "id: " + id + "\n";
  yaml += "name: null\n";
  yaml += "status: interviewing\n";
  yaml += "createdBy: " + userId + "\n";
  yaml += "createdAt: " + mate.createdAt + "\n";
  yaml += "relationship: " + (seedData.relationship || "assistant") + "\n";
  yaml += "activities: " + JSON.stringify(seedData.activity || []) + "\n";
  yaml += "vendor: " + (seedData.vendor || "claude") + "\n";
  yaml += "model: " + JSON.stringify(seedData.model || null) + "\n";
  fs.writeFileSync(path.join(mateDir, "mate.yaml"), yaml);

  // Write initial AGENTS.md (will be replaced by interview)
  var claudeMd = "# Mate Identity\n\n";
  claudeMd += "This mate is currently being interviewed. Identity will be generated after the interview.\n\n";
  claudeMd += "## Seed Data\n\n";
  claudeMd += "- Relationship: " + (seedData.relationship || "assistant") + "\n";
  if (seedData.activity && seedData.activity.length > 0) {
    claudeMd += "- Activities: " + seedData.activity.join(", ") + "\n";
  }
  if (seedData.communicationStyle && seedData.communicationStyle.length > 0) {
    claudeMd += "- Communication: " + seedData.communicationStyle.join(", ") + "\n";
  }
  var initialIdentity = claudeMd.trimEnd();
  claudeMd += TEAM_SECTION;
  claudeMd += SESSION_MEMORY_SECTION;
  claudeMd += crisisSafety.getSection();
  fs.writeFileSync(resolveMateIdentityFile(mateDir), claudeMd);

  // Log creation (identity is placeholder, will be replaced by interview)
  logIdentityChange(mateDir, "create_custom", initialIdentity, "");

  return mate;
}

function getMate(ctx, id) {
  var data = loadMates(ctx);
  for (var i = 0; i < data.mates.length; i++) {
    if (data.mates[i].id === id) return data.mates[i];
  }
  return null;
}

function updateMate(ctx, id, updates) {
  updates = Object.assign({}, updates);
  delete updates.id;
  delete updates.createdBy;
  delete updates.builtinKey;
  var data = loadMates(ctx);
  for (var i = 0; i < data.mates.length; i++) {
    if (data.mates[i].id === id) {
      if (Object.prototype.hasOwnProperty.call(updates, "workspaceColor")) {
        if (data.mates[i].primary || data.mates[i].builtinKey === "clay") delete updates.workspaceColor;
        else if (!workspaceColor.valid(updates.workspaceColor)) return { error: "Choose a supported workspace color." };
      }
      var avatarError = avatarCatalog.validate(updates.profile, data.mates[i]);
      if (avatarError) return { error: avatarError };
      // Primary mates: protect name and core identity fields
      if (data.mates[i].primary) {
        delete updates.name;
        delete updates.bio;
        delete updates.primary;
        delete updates.globalSearch;
        delete updates.templateVersion;
        if (updates.profile) {
          delete updates.profile.displayName;
        }
      }
      // Locked built-in artwork is server-managed. Preserve it even when a
      // stale or crafted client submits a replacement profile object.
      if (data.mates[i].profile && data.mates[i].profile.avatarLocked && updates.profile) {
        updates.profile.avatarCatalogId = data.mates[i].profile.avatarCatalogId || "";
        updates.profile.avatarStyle = data.mates[i].profile.avatarStyle;
        updates.profile.avatarSeed = data.mates[i].profile.avatarSeed;
        updates.profile.avatarColor = data.mates[i].profile.avatarColor;
        updates.profile.avatarCustom = data.mates[i].profile.avatarCustom;
        updates.profile.avatarLocked = true;
      }
      var keys = Object.keys(updates);
      for (var j = 0; j < keys.length; j++) {
        data.mates[i][keys[j]] = updates[keys[j]];
      }
      avatarMigration.normalizeMate(data.mates[i]);
      saveMates(ctx, data);
      return data.mates[i];
    }
  }
  return null;
}

function deleteMate(ctx, id, options) {
  var data = loadMates(ctx);
  // Primary mates cannot be deleted (they are system infrastructure)
  for (var di = 0; di < data.mates.length; di++) {
    if (data.mates[di].id === id && data.mates[di].primary) {
      return { error: "Primary mates cannot be deleted. They are managed by the system." };
    }
  }
  var before = data.mates.length;
  data.mates = data.mates.filter(function (m) {
    return m.id !== id;
  });
  if (data.mates.length === before) return { error: "Mate not found" };
  if (!options || !options.strictCleanup) saveMates(ctx, data);

  // Remove mate directory
  var mateDir = getMateDir(ctx, id);
  try {
    fs.rmSync(mateDir, { recursive: true, force: true });
  } catch (e) {
    if (options && options.strictCleanup) throw e;
  }
  if (options && options.strictCleanup) saveMates(ctx, data);

  return { ok: true };
}

function getAllMates(ctx) {
  var data = loadMates(ctx);
  return data.mates;
}

function isMate(ctx, id) {
  if (!id) return false;
  if (typeof id === "string" && id.indexOf("mate_") === 0) {
    // Double check it exists in registry
    return !!getMate(ctx, id);
  }
  return false;
}

function getMateDir(ctx, id) {
  return path.join(resolveMatesRoot(ctx), id);
}

// --- Migration ---

function migrateLegacyMates() {
  var legacyFile = path.join(config.CONFIG_DIR, "mates.json");
  if (!fs.existsSync(legacyFile)) return;

  // Check if already migrated
  var migratedMarker = legacyFile + ".migrated";
  if (fs.existsSync(migratedMarker)) return;

  try {
    var raw = fs.readFileSync(legacyFile, "utf8");
    var data = JSON.parse(raw);
    if (!data.mates || data.mates.length === 0) {
      // Nothing to migrate, just mark as done
      fs.renameSync(legacyFile, migratedMarker);
      return;
    }

    // Group mates by createdBy
    var byUser = {};
    for (var i = 0; i < data.mates.length; i++) {
      var m = data.mates[i];
      var key = m.createdBy || "__null__";
      if (!byUser[key]) byUser[key] = [];
      byUser[key].push(m);
    }

    // Write each user's mates to their own storage path
    var keys = Object.keys(byUser);
    for (var k = 0; k < keys.length; k++) {
      var userId = keys[k] === "__null__" ? null : keys[k];
      var ctx = buildMateCtx(userId);
      var userData = { mates: byUser[keys[k]] };
      saveMates(ctx, userData);

      // Move mate identity directories to new location
      var legacyMatesDir = path.join(config.CONFIG_DIR, "mates");
      var newRoot = resolveMatesRoot(ctx);
      for (var mi = 0; mi < byUser[keys[k]].length; mi++) {
        var mateId = byUser[keys[k]][mi].id;
        var oldDir = path.join(legacyMatesDir, mateId);
        var newDir = path.join(newRoot, mateId);
        if (fs.existsSync(oldDir) && oldDir !== newDir) {
          fs.mkdirSync(path.dirname(newDir), { recursive: true });
          try {
            fs.renameSync(oldDir, newDir);
          } catch (e) {
            // Cross-device or other issue, copy instead
            fs.cpSync(oldDir, newDir, { recursive: true });
            fs.rmSync(oldDir, { recursive: true, force: true });
          }
        }
      }
    }

    // Mark legacy file as migrated
    fs.renameSync(legacyFile, migratedMarker);
    console.log("[mates] Migrated legacy mates.json to per-user storage");
  } catch (e) {
    console.error("[mates] Legacy migration failed:", e.message);
  }
}

/**
 * Strip all system sections from AGENTS.md content, returning only identity.
 */
function stripAllSystemSections(content) {
  return extractIdentity(content);
}

// Tracks paths we've already warned about missing identity (avoids log spam)
var _warnedPaths = {};

/**
 * Atomic enforce: read AGENTS.md once, enforce all system sections, write once.
 * Includes identity backup, validation, and change tracking.
 * Returns true if the file was modified, false if already correct.
 * @param {string} filePath - path to AGENTS.md
 * @param {object} opts - optional { ctx, mateId } for dynamic team section
 */
function enforceAllSections(filePath, opts) {
  if (!fs.existsSync(filePath)) return false;
  opts = opts || {};

  var content = fs.readFileSync(filePath, "utf8");
  var mateDir = path.dirname(filePath);

  // 1. Extract current identity (everything before system markers)
  var identity = extractIdentity(content);
  var identityMissing = !identity || identity.length < IDENTITY_MIN_LENGTH;

  // 2. If identity is empty or suspiciously short, try to restore from backup
  if (identityMissing) {
    var backup = loadIdentityBackup(mateDir);
    if (backup) {
      console.log("[mates] Restoring identity from backup: " + filePath + " (" + backup.length + " chars)");
      identity = backup;
      logIdentityChange(mateDir, "restore_from_backup", identity, "");
      identityMissing = false;
    }
  }

  // 3. Backup identity if it's substantive
  backupIdentity(mateDir, identity);

  // 4. Rebuild the full file: identity + all system sections in order
  //    Use dynamic team section when ctx is available, static fallback otherwise
  var teamSection = (opts.ctx && opts.mateId) ? buildTeamSection(opts.ctx, opts.mateId, loadMates) : TEAM_SECTION;

  // Primary mate capabilities (dynamically injected from code)
  var capSection = "";
  if (opts.ctx && opts.mateId) {
    try {
      var mate = getMate(opts.ctx, opts.mateId);
      capSection = buildPrimaryCapabilitiesSection(mate);
    } catch (e) {}
  }

  // Project registry (dynamically injected when project list is available)
  var projSection = "";
  if (opts.projects) {
    projSection = buildProjectRegistrySection(opts.projects);
  }

  var rebuilt = (identity || "").trimEnd();
  rebuilt += teamSection;
  rebuilt += projSection;
  rebuilt += capSection;
  rebuilt += SESSION_MEMORY_SECTION;
  rebuilt += STICKY_NOTES_SECTION;
  rebuilt += DEBATE_AWARENESS_SECTION;
  rebuilt += crisisSafety.getSection();

  // 5. Only write if content actually changed
  if (rebuilt === content) return false;

  // 6. Warn about missing identity only once per path, and only when actually writing
  if (identityMissing && !_warnedPaths[filePath]) {
    _warnedPaths[filePath] = true;
    console.log("[mates] WARNING: Identity missing in " + filePath + " and no backup available");
  }

  // 7. Track identity changes (compare stripped versions)
  var prevIdentity = stripAllSystemSections(content);
  if (identity !== prevIdentity) {
    logIdentityChange(mateDir, "enforce", identity, prevIdentity);
  }

  fs.writeFileSync(filePath, rebuilt, "utf8");
  return true;
}

// Format seed data as a human-readable context string
function formatSeedContext(seedData) {
  if (!seedData) return "";
  var parts = [];

  if (seedData.relationship) {
    parts.push("The user wants a " + seedData.relationship + " relationship.");
  }

  if (seedData.activity && seedData.activity.length > 0) {
    parts.push("Primary activities: " + seedData.activity.join(", ") + ".");
  }

  if (seedData.communicationStyle && seedData.communicationStyle.length > 0) {
    var styleLabels = {
      direct_concise: "direct and concise",
      soft_detailed: "soft and detailed",
      witty: "witty",
      encouraging: "encouraging",
      formal: "formal",
      no_nonsense: "no-nonsense",
    };
    var styles = seedData.communicationStyle.map(function (s) { return styleLabels[s] || s.replace(/_/g, " "); });
    parts.push("Communication style: " + styles.join(", ") + ".");
  }

  if (seedData.autonomy) {
    var autonomyLabels = {
      always_ask: "Always ask before acting",
      minor_stuff_ok: "Handle minor stuff without asking",
      mostly_autonomous: "Mostly autonomous, ask for big decisions",
      fully_autonomous: "Fully autonomous",
    };
    parts.push("Autonomy: " + (autonomyLabels[seedData.autonomy] || seedData.autonomy) + ".");
  }

  return parts.join(" ");
}

// --- Built-in mates ---

function createBuiltinMate(ctx, builtinKey) {
  var builtinMates = require("./builtin-mates");
  var def = builtinMates.getBuiltinByKey(builtinKey);
  if (!def) throw new Error("Unknown built-in mate key: " + builtinKey);

  var data = loadMates(ctx);
  var id = generateMateId();
  var userId = ctx ? ctx.userId : null;

  var mate = {
    id: id,
    builtinKey: builtinKey,
    name: def.displayName,
    createdBy: userId,
    createdAt: Date.now(),
    seedData: def.seedData,
    profile: {
      displayName: def.displayName,
      avatarColor: def.avatarColor,
      avatarStyle: def.avatarStyle,
      avatarSeed: def.avatarCustom ? crypto.randomBytes(4).toString("hex") : def.displayName,
      avatarCustom: def.avatarCustom || "",
      avatarLocked: !!def.avatarLocked,
    },
    bio: def.bio,
    status: "ready",
    primary: !!def.primary,
    globalSearch: !!def.globalSearch,
    templateVersion: def.templateVersion || 0,
    interviewProjectPath: null,
  };

  avatarMigration.normalizeMate(mate);
  data.mates.push(mate);
  saveMates(ctx, data);

  // Create the mate's identity directory
  var mateDir = getMateDir(ctx, id);
  fs.mkdirSync(mateDir, { recursive: true });

  // Create knowledge directory
  fs.mkdirSync(path.join(mateDir, "knowledge"), { recursive: true });

  // Write mate.yaml
  var seedData = def.seedData;
  var yaml = "# Mate metadata\n";
  yaml += "id: " + id + "\n";
  yaml += "name: " + def.displayName + "\n";
  yaml += "status: ready\n";
  yaml += "builtinKey: " + builtinKey + "\n";
  yaml += "createdBy: " + userId + "\n";
  yaml += "createdAt: " + mate.createdAt + "\n";
  yaml += "relationship: " + (seedData.relationship || "assistant") + "\n";
  yaml += "activities: " + JSON.stringify(seedData.activity || []) + "\n";
  yaml += "autonomy: " + (seedData.autonomy || "always_ask") + "\n";
  fs.writeFileSync(path.join(mateDir, "mate.yaml"), yaml);

  // Write AGENTS.md with full template + system sections
  var claudeMd = def.getClaudeMd();
  var builtinIdentity = claudeMd.trimEnd();
  claudeMd += TEAM_SECTION;
  claudeMd += SESSION_MEMORY_SECTION;
  claudeMd += STICKY_NOTES_SECTION;
  claudeMd += DEBATE_AWARENESS_SECTION;
  claudeMd += crisisSafety.getSection();
  fs.writeFileSync(resolveMateIdentityFile(mateDir), claudeMd);

  // Backup identity and log creation
  backupIdentity(mateDir, builtinIdentity);
  logIdentityChange(mateDir, "create_builtin", builtinIdentity, "");

  // Save base template for primary mates (used for 3-way sync comparison)
  if (def.primary) {
    try {
      fs.writeFileSync(path.join(mateDir, "knowledge", "base-template.md"), builtinIdentity, "utf8");
    } catch (e) {}
  }

  return mate;
}

function getInstalledBuiltinKeys(ctx) {
  var data = loadMates(ctx);
  var keys = [];
  for (var i = 0; i < data.mates.length; i++) {
    if (data.mates[i].builtinKey) {
      keys.push(data.mates[i].builtinKey);
    }
  }
  return keys;
}

function getMissingBuiltinKeys(ctx) {
  var builtinMates = require("./builtin-mates");
  var allKeys = builtinMates.getBuiltinKeys();
  var installed = getInstalledBuiltinKeys(ctx);
  var missing = [];
  for (var i = 0; i < allKeys.length; i++) {
    if (installed.indexOf(allKeys[i]) === -1) {
      missing.push(allKeys[i]);
    }
  }
  return missing;
}

function ensureBuiltinMates(ctx, deletedKeys) {
  var missing = getMissingBuiltinKeys(ctx);
  var excluded = deletedKeys || [];
  var created = [];
  for (var i = 0; i < missing.length; i++) {
    if (excluded.indexOf(missing[i]) === -1) {
      created.push(createBuiltinMate(ctx, missing[i]));
    }
  }
  // Demote any archived built-ins that the user already has installed:
  // strip primary/globalSearch flags and set archived=true on the mate
  // object so the active-list filter hides them. Conversation history is
  // untouched. This runs every startup so the transition lands even on
  // users who logged in once when Ally was primary.
  syncArchivedBuiltinMates(ctx);
  return created;
}

function syncArchivedBuiltinMates(ctx) {
  var builtinMates = require("./builtin-mates");
  var archivedKeys = builtinMates.getArchivedBuiltinKeys();
  if (archivedKeys.length === 0) return;
  var data = loadMates(ctx);
  var changed = false;
  for (var i = 0; i < data.mates.length; i++) {
    var m = data.mates[i];
    if (!m.builtinKey) continue;
    if (archivedKeys.indexOf(m.builtinKey) === -1) continue;
    if (!m.archived) { m.archived = true; changed = true; }
    if (m.primary) { m.primary = false; changed = true; }
    if (m.globalSearch) { m.globalSearch = false; changed = true; }
  }
  if (changed) saveMates(ctx, data);
}

/**
 * Sync primary mates with their latest code definition.
 *
 * Primary mates (def.primary === true) are system infrastructure, not just
 * pre-made mates. They are the only mates whose identity, metadata, and
 * capabilities are managed by code rather than by the user.
 *
 * What gets synced:
 * - Metadata: bio, name, profile, globalSearch, primary flag
 * - AGENTS.md identity: re-applied when templateVersion changes
 * - templateVersion is stored on the mate object to track updates
 *
 * What is NOT synced (regular builtin mates):
 * - Arch, Rush, Ward, Pixel, Buzz are left as-is after creation
 *
 * Returns array of synced mate IDs.
 */
function syncPrimaryMates(ctx) {
  var builtinMates = require("./builtin-mates");
  var primaryDefs = builtinMates.getPrimaryMates();
  if (primaryDefs.length === 0) return [];

  var data = loadMates(ctx);
  var synced = [];

  for (var pi = 0; pi < primaryDefs.length; pi++) {
    var def = primaryDefs[pi];

    // Find the installed mate matching this primary definition
    var mate = null;
    for (var mi = 0; mi < data.mates.length; mi++) {
      if (data.mates[mi].builtinKey === def.key) { mate = data.mates[mi]; break; }
    }
    if (!mate) continue; // not installed yet (ensureBuiltinMates handles creation)

    var changed = false;

    // --- Sync metadata (always, every startup) ---
    if (mate.bio !== def.bio) { mate.bio = def.bio; changed = true; }
    if (mate.name !== def.displayName) { mate.name = def.displayName; changed = true; }
    if (!mate.primary) { mate.primary = true; changed = true; }
    if (!mate.globalSearch && def.globalSearch) { mate.globalSearch = true; changed = true; }
    if (mate.profile) {
      if (mate.profile.displayName !== def.displayName) {
        mate.profile.displayName = def.displayName; changed = true;
      }
      // Locked avatars are code-managed — refresh them from the def so a
      // built-in's avatar can be updated by changing the source file
      // User uploads are preserved while canonical library artwork follows the definition.
      if (def.avatarLocked && !avatarMigration.hasCustom(mate)) {
        if (def.avatarCustom != null && mate.profile.avatarCustom !== def.avatarCustom) {
          mate.profile.avatarCustom = def.avatarCustom; changed = true;
        }
        if (def.avatarColor && mate.profile.avatarColor !== def.avatarColor) {
          mate.profile.avatarColor = def.avatarColor; changed = true;
        }
      }
    }

    // --- Sync AGENTS.md identity (only when templateVersion changes) ---
    // Uses 3-way comparison to preserve user/Ally modifications:
    // - base-template.md = the template that was last synced (what code wrote)
    // - current identity = what's in AGENTS.md now (may have been modified)
    // - new template = latest code definition
    //
    // If current identity === old base → user didn't touch it → safe to replace
    // If current identity !== old base → user/Ally modified it → preserve, skip identity sync
    var currentVersion = mate.templateVersion || 0;
    var latestVersion = def.templateVersion || 0;

    if (latestVersion > currentVersion) {
      var mateDir = getMateDir(ctx, mate.id);
      var claudePath = resolveMateIdentityFile(mateDir);
      var basePath = path.join(mateDir, "knowledge", "base-template.md");
      var latestIdentity = def.getClaudeMd().trimEnd();

      try {
        var currentIdentity = "";
        if (fs.existsSync(claudePath)) {
          currentIdentity = extractIdentity(fs.readFileSync(claudePath, "utf8"));
        }

        // Load the base template from last sync
        var oldBase = "";
        try { oldBase = fs.readFileSync(basePath, "utf8").trimEnd(); } catch (e) {}

        var shouldUpdateIdentity = false;
        if (!currentIdentity || currentIdentity.length < IDENTITY_MIN_LENGTH) {
          // No identity at all, always apply
          shouldUpdateIdentity = true;
        } else if (!oldBase || currentIdentity === oldBase) {
          // Identity unchanged from last sync (user didn't modify) → safe to update
          shouldUpdateIdentity = true;
        } else {
          // User/Ally modified the identity since last sync → preserve their changes
          console.log("[mates] Primary mate " + mate.name + ": identity modified by user/Ally, preserving (v" + currentVersion + " -> v" + latestVersion + " metadata only)");
        }

        if (shouldUpdateIdentity) {
          // Write just the identity, then let enforceAllSections rebuild system sections
          // (including dynamic capabilities section)
          fs.writeFileSync(claudePath, latestIdentity, "utf8");
          backupIdentity(mateDir, latestIdentity);
          logIdentityChange(mateDir, "sync_primary_v" + latestVersion, latestIdentity, currentIdentity);
          console.log("[mates] Primary mate identity updated: " + mate.name + " (v" + currentVersion + " -> v" + latestVersion + ")");
        } else {
          console.log("[mates] Primary mate metadata updated: " + mate.name + " (v" + currentVersion + " -> v" + latestVersion + ", identity preserved)");
        }

        // Re-run enforceAllSections to inject latest dynamic sections (capabilities, team, etc.)
        enforceAllSections(claudePath, { ctx: ctx, mateId: mate.id });

        // Save the latest template as base for future 3-way comparison
        try {
          fs.mkdirSync(path.join(mateDir, "knowledge"), { recursive: true });
          fs.writeFileSync(basePath, latestIdentity, "utf8");
        } catch (e) {}

      } catch (e) {
        console.error("[mates] Failed to sync primary mate " + mate.id + ":", e.message);
      }

      mate.templateVersion = latestVersion;
      changed = true;
    }

    if (changed) synced.push(mate.id);
  }

  if (synced.length > 0) saveMates(ctx, data);
  return synced;
}

module.exports = {
  resolveMatesRoot: resolveMatesRoot,
  buildMateCtx: buildMateCtx,
  isMateIdFormat: isMateIdFormat,
  loadMates: loadMates,
  saveMates: saveMates,
  createMate: createMate,
  getMate: getMate,
  updateMate: updateMate,
  deleteMate: deleteMate,
  getAllMates: getAllMates,
  isMate: isMate,
  getMateDir: getMateDir,
  migrateLegacyMates: migrateLegacyMates,
  formatSeedContext: formatSeedContext,
  enforceTeamAwareness: enforceTeamAwareness,
  TEAM_MARKER: TEAM_MARKER,
  TEAM_SECTION: TEAM_SECTION,
  enforceSessionMemory: enforceSessionMemory,
  SESSION_MEMORY_MARKER: SESSION_MEMORY_MARKER,
  SESSION_MEMORY_SECTION: SESSION_MEMORY_SECTION,
  loadCommonKnowledge: loadCommonKnowledge,
  promoteKnowledge: promoteKnowledge,
  depromoteKnowledge: depromoteKnowledge,
  getCommonKnowledgeForMate: getCommonKnowledgeForMate,
  readCommonKnowledgeFile: readCommonKnowledgeFile,
  isPromoted: isPromoted,
  enforceStickyNotes: enforceStickyNotes,
  STICKY_NOTES_MARKER: STICKY_NOTES_MARKER,
  STICKY_NOTES_SECTION: STICKY_NOTES_SECTION,
  enforceProjectRegistry: enforceProjectRegistry,
  buildProjectRegistrySection: buildProjectRegistrySection,
  PROJECT_REGISTRY_MARKER: PROJECT_REGISTRY_MARKER,
  enforceDebateAwareness: enforceDebateAwareness,
  DEBATE_AWARENESS_MARKER: DEBATE_AWARENESS_MARKER,
  DEBATE_AWARENESS_SECTION: DEBATE_AWARENESS_SECTION,
  enforceAllSections: enforceAllSections,
  buildTeamSection: buildTeamSection,
  buildPrimaryCapabilitiesSection: buildPrimaryCapabilitiesSection,
  PRIMARY_CAPABILITIES_MARKER: PRIMARY_CAPABILITIES_MARKER,
  extractIdentity: extractIdentity,
  backupIdentity: backupIdentity,
  loadIdentityBackup: loadIdentityBackup,
  logIdentityChange: logIdentityChange,
  createBuiltinMate: createBuiltinMate,
  getInstalledBuiltinKeys: getInstalledBuiltinKeys,
  getMissingBuiltinKeys: getMissingBuiltinKeys,
  ensureBuiltinMates: ensureBuiltinMates,
  syncArchivedBuiltinMates: syncArchivedBuiltinMates,
  syncPrimaryMates: syncPrimaryMates,
};