arcane-cli 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -237,12 +237,16 @@ Codex, VS Code Copilot and Claude Code each discover the user tier from their ow
237
237
 
238
238
  ```bash
239
239
  spell agents init --user # ~/.arcane/agents.yaml plus one definition file per role
240
- spell agents sync --user # renders ~/.copilot/agents/<name>.agent.md — VS Code agent modes
240
+ spell agents sync --user # renders ~/.claude/agents/<name>.md — read by VS Code and Claude Code
241
241
  spell agents list --user # the roster the tier holds
242
242
  ```
243
243
 
244
244
  Unlike spells, agent files are never delivered by `spell init` or `spell update`: they are rendered from your roster, whose naming strategy decides each file's name and contents, so an upgrade leaves them alone by design (ARC-047). And unlike skills, VS Code does **not** collapse two agents that share a name — a repository carrying its own `.github/agents` files adds a second entry beside the tier's rather than replacing it, so opting the repository out is what produces one set.
245
245
 
246
+ `~/.claude/agents` is read by VS Code **and** by Claude Code, so one file gives you the persona in both. Each description ends by saying the persona is for when you ask for it by name: Claude Code reads that field to decide whether to route work to a subagent unprompted, and twelve personas installed once per machine would otherwise start answering in projects that have nothing to do with Arcane. Change that sentence in `renderUserTierAgent` if you want the opposite.
247
+
248
+ A repository that opts in and has no roster of its own now takes the machine-wide one for its roster tables, rather than refusing.
249
+
246
250
  **Then let a repository stop carrying its own copies.** `spell init` offers this when the machine already has a tier, and an existing repository opts in by setting one field:
247
251
 
248
252
  ```jsonc
@@ -52,7 +52,7 @@ Paste this entire file into any AI client (Claude web, ChatGPT, etc.) to give it
52
52
  ## Where Documents Live
53
53
 
54
54
  - **Framework-managed standards:** `.arcane/governance/`. Spells reference this single installed layer; **do not create a duplicate root `governance/` tree. Enforcement: explicitly advisory prose (ARC-023) — no check scans a repo for a duplicate root-level `governance/` directory.**
55
- - **Framework-managed spells:** `.arcane/spells/<id>.md` is each spell's one authored source (ARC-045). The files each AI client discovers — `.github/prompts/<id>.prompt.md` for Copilot, `.claude/commands/<id>.md` for Claude Code, `.agents/skills/<id>/SKILL.md` for Codex — are thin generated shims that point back at it; edit the canonical file, never a shim. **Enforcement: executable check (ARC-023) — `npm run check:self-host-parity` re-renders every shim from its canonical file and fails on any difference; `spell update` keeps a hand-edited shim untouched and names it rather than merging into it.** The same canonical spells can also be installed once per machine — `spell init --user` puts them at `~/.arcane/spells/<id>.md` with one Codex/Copilot skill under `~/.agents/skills/` and one Claude Code command under `~/.claude/commands/` per spell (ARC-045 decision 3); governance never moves up a tier. The agent roster follows the same shape (ARC-047): `spell agents init|sync --user` keeps `agents.yaml` and the role definitions at `~/.arcane` and renders one `~/.copilot/agents/<name>.agent.md` per agent, which is where VS Code looks for machine-wide agent modes. Agent files are never installed by `spell init`/`spell update` at either tier -- they are rendered from the roster, whose naming strategy picks each file's name, which is why an upgrade never touches them. A repository may then opt out of carrying its own copies entirely with `spell_scope: "user"` in `.arcane.json` (ARC-045 decision 4) — `spell update --prune` removes what it no longer manages, keeping anything you edited, and `spell doctor` fails loudly if the tier it now depends on is missing.
55
+ - **Framework-managed spells:** `.arcane/spells/<id>.md` is each spell's one authored source (ARC-045). The files each AI client discovers — `.github/prompts/<id>.prompt.md` for Copilot, `.claude/commands/<id>.md` for Claude Code, `.agents/skills/<id>/SKILL.md` for Codex — are thin generated shims that point back at it; edit the canonical file, never a shim. **Enforcement: executable check (ARC-023) — `npm run check:self-host-parity` re-renders every shim from its canonical file and fails on any difference; `spell update` keeps a hand-edited shim untouched and names it rather than merging into it.** The same canonical spells can also be installed once per machine — `spell init --user` puts them at `~/.arcane/spells/<id>.md` with one Codex/Copilot skill under `~/.agents/skills/` and one Claude Code command under `~/.claude/commands/` per spell (ARC-045 decision 3); governance never moves up a tier. The agent roster follows the same shape (ARC-047): `spell agents init|sync --user` keeps `agents.yaml` and the role definitions at `~/.arcane` and renders one `~/.claude/agents/<name>.md` per agent -- the one home location read by both VS Code and Claude Code, so a persona shows up once in each rather than twice in one (ARC-047 decisions 3 and 11). Agent files are never installed by `spell init`/`spell update` at either tier -- they are rendered from the roster, whose naming strategy picks each file's name, which is why an upgrade never touches them. A repository may then opt out of carrying its own copies entirely with `spell_scope: "user"` in `.arcane.json` (ARC-045 decision 4) — `spell update --prune` removes what it no longer manages, keeping anything you edited, and `spell doctor` fails loudly if the tier it now depends on is missing.
56
56
  - **Project-owned orientation and continuity:** `README.md`, `project.md`, `TODO.md`, `DECISIONS.md`, `ai-context/`, and `journal/`. Arcane creates missing files once and preserves existing content.
57
57
  - **Project/domain documents:** use explicit descriptive paths such as `docs/`, `security/`, `infrastructure/`, or a configured business root. These add project context alongside framework standards.
58
58
  - **Overrides survive `arcane update`:** editing a managed `.arcane/governance/` standard no longer risks silent loss. Additive project documents were always safe; a hand-edit to a shipped standard now is too. **Enforcement: executable check (ARC-038, shipped BC-31)** — `spell update` compares each file's on-disk content against its recorded hash at install time: unchanged, it refreshes normally; edited, it fetches the exact previously-installed published version as a three-way merge base against your edit and the new vendor content, auto-merging non-overlapping changes and leaving standard conflict markers only on genuine collisions. If the prior published version can't be fetched, your edit is left untouched with a warning rather than replaced.
package/dist/index.js CHANGED
@@ -1035,25 +1035,27 @@ function spellIdsInStore(components) {
1035
1035
  }
1036
1036
  return [...ids].sort();
1037
1037
  }
1038
- var USER_AGENT_FANOUT_DIR = ".copilot/agents";
1038
+ var USER_AGENT_FANOUT_DIR = ".claude/agents";
1039
+ var LEGACY_USER_AGENT_FANOUT_DIR = ".copilot/agents";
1039
1040
  var SPELL_FANOUT_CLIENTS = ["codex", "claude"];
1040
1041
  var USER_FANOUT_PATHS = {
1041
1042
  codex: (id) => `.agents/skills/${id}/SKILL.md`,
1042
1043
  claude: (id) => `.claude/commands/${id}.md`
1043
1044
  };
1044
1045
  function userAgentFanoutPath(slug) {
1045
- return `${USER_AGENT_FANOUT_DIR}/${slug}.agent.md`;
1046
+ return `${USER_AGENT_FANOUT_DIR}/${slug}.md`;
1046
1047
  }
1047
1048
  var FANOUT_CLIENT_LABELS = {
1048
1049
  codex: "Codex/Copilot skills",
1049
1050
  claude: "Claude Code commands",
1050
- "copilot-agents": "Copilot agent modes"
1051
+ "copilot-agents": "agent personas (VS Code + Claude Code)"
1051
1052
  };
1052
1053
  function clientOfFanoutPath(relativePath) {
1053
1054
  const normalized = relativePath.replace(/\\/g, "/");
1054
1055
  if (normalized.startsWith(".agents/skills/")) return "codex";
1055
1056
  if (normalized.startsWith(".claude/commands/")) return "claude";
1056
1057
  if (normalized.startsWith(`${USER_AGENT_FANOUT_DIR}/`)) return "copilot-agents";
1058
+ if (normalized.startsWith(`${LEGACY_USER_AGENT_FANOUT_DIR}/`)) return "copilot-agents";
1057
1059
  return void 0;
1058
1060
  }
1059
1061
  async function readSpellSource(storeRoot, id, fallbackDir) {
@@ -2050,6 +2052,16 @@ ${allowed}
2050
2052
  ${denied}
2051
2053
  `;
2052
2054
  }
2055
+ function renderUserTierAgent(def, name) {
2056
+ const collapsed = def.persona.description.replace(/\s+/g, " ").trim();
2057
+ const firstSentence = /^.*?[.!?](?=\s|$)/.exec(collapsed)?.[0] ?? def.role;
2058
+ const repoFile = renderCopilotAgent(def, name);
2059
+ const body = repoFile.slice(repoFile.indexOf("---", 3) + 3);
2060
+ return `---
2061
+ name: ${name}
2062
+ description: ${def.role}. ${firstSentence} Use only when explicitly asked for ${name} by name; do not delegate to this persona automatically.
2063
+ ---${body}`;
2064
+ }
2053
2065
  function renderCopilotAgent(def, name) {
2054
2066
  const firstLine = def.persona.description.split("\n")[0]?.trim() ?? def.role;
2055
2067
  const rules = def.persona.behavioral_rules.map((r) => `- ${r}`).join("\n");
@@ -2130,7 +2142,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2130
2142
  const repoOptedOut = !userTier && options.spellScope === "user";
2131
2143
  const userAgentFiles = [];
2132
2144
  const unmanagedAgentFiles = [];
2133
- const projectDefs = projectAgentsDir(targetDir, userTier ? "user" : "repo");
2145
+ const projectDefs = options.definitionsDir ?? projectAgentsDir(targetDir, userTier ? "user" : "repo");
2134
2146
  const bundledDefs = join3(assetsDir, "agents");
2135
2147
  const resolved = [];
2136
2148
  for (const entry of roster.roster) {
@@ -2224,7 +2236,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2224
2236
  if (!def.clients.copilot?.agent_file) continue;
2225
2237
  const slug = displayName.toLowerCase().replace(/\s+/g, "-");
2226
2238
  const fileName = `${slug}.agent.md`;
2227
- const content = renderCopilotAgent(def, displayName);
2239
+ const content = userTier ? renderUserTierAgent(def, displayName) : renderCopilotAgent(def, displayName);
2228
2240
  if (userTier) {
2229
2241
  const relativePath = userAgentFanoutPath(slug);
2230
2242
  userAgentFiles.push({ relativePath, client: "copilot-agents", content });
@@ -2301,6 +2313,24 @@ async function requireUserStore(storeRoot) {
2301
2313
  throw new Error("unreachable");
2302
2314
  }
2303
2315
  }
2316
+ async function resolveRoster(targetDir, scope, homeDir) {
2317
+ try {
2318
+ return {
2319
+ roster: await loadRoster(targetDir, scope),
2320
+ definitionsDir: projectAgentsDir(targetDir, scope),
2321
+ fromUserTier: scope === "user"
2322
+ };
2323
+ } catch (err) {
2324
+ const missing = err instanceof AgentRosterNotFoundError;
2325
+ if (!missing || scope !== "repo" || await repoSpellScope(targetDir) !== "user") throw err;
2326
+ const storeRoot = homeDir === void 0 ? userTierRoot() : userTierRoot(homeDir);
2327
+ return {
2328
+ roster: await loadRoster(storeRoot, "user"),
2329
+ definitionsDir: projectAgentsDir(storeRoot, "user"),
2330
+ fromUserTier: true
2331
+ };
2332
+ }
2333
+ }
2304
2334
  async function repoSpellScope(targetDir) {
2305
2335
  try {
2306
2336
  return effectiveSpellScope(await readManifest(targetDir));
@@ -2487,7 +2517,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2487
2517
  printSuccess("Agent roster initialized");
2488
2518
  console.log(` \u{1F4CB} Profile: ${profileId} \xB7 Naming: ${namingStrategy} \xB7 Agents: ${rosterEntries.length}`);
2489
2519
  console.log(
2490
- ` \u{1F504} Synced: ${synced.length} outputs (${scope === "user" ? "Copilot agent modes in ~/.copilot/agents" : "Copilot, Claude, Codex, OpenClaw"})`
2520
+ ` \u{1F504} Synced: ${synced.length} outputs (${scope === "user" ? "agent personas in ~/.claude/agents" : "Copilot, Claude, Codex, OpenClaw"})`
2491
2521
  );
2492
2522
  if (skipped.length > 0) {
2493
2523
  console.log(` \u26A0\uFE0F Skipped: ${skipped.length} (${skipped.join(", ")})`);
@@ -2507,8 +2537,10 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2507
2537
  async function runAgentsSync(options, targetDir, assetsDir) {
2508
2538
  const scope = options.scope ?? "repo";
2509
2539
  let roster;
2540
+ let definitionsDir;
2541
+ let fromUserTier = false;
2510
2542
  try {
2511
- roster = await loadRoster(targetDir, scope);
2543
+ ({ roster, definitionsDir, fromUserTier } = await resolveRoster(targetDir, scope));
2512
2544
  } catch (err) {
2513
2545
  if (err instanceof AgentRosterNotFoundError || err instanceof AgentConfigValidationError) {
2514
2546
  console.error(err.message);
@@ -2522,11 +2554,16 @@ async function runAgentsSync(options, targetDir, assetsDir) {
2522
2554
  } else {
2523
2555
  console.log("\n Syncing agents...\n");
2524
2556
  }
2557
+ if (fromUserTier && scope === "repo") {
2558
+ console.log(
2559
+ " This repository has no roster of its own and takes its spells from the user tier,\n so its roster tables are built from ~/.arcane/agents.yaml.\n"
2560
+ );
2561
+ }
2525
2562
  const { synced, skipped, hasUnresolvedRoles } = await performAgentSync(
2526
2563
  targetDir,
2527
2564
  assetsDir,
2528
2565
  roster,
2529
- { ...options, scope }
2566
+ { ...options, scope, ...definitionsDir === void 0 ? {} : { definitionsDir } }
2530
2567
  );
2531
2568
  if (options.dryRun) {
2532
2569
  console.log(`