arcane-cli 1.1.1 → 1.2.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
@@ -231,7 +231,24 @@ spell update --user # after upgrading the CLI
231
231
  spell uninstall --user # removes only what it wrote; an edited client file is kept and named
232
232
  ```
233
233
 
234
- Codex, VS Code Copilot and Claude Code each discover the user tier from their own home-directory locations (`~/.agents/skills` and `~/.claude/commands`), so no VS Code setting is needed. Claude Code runs a personal command over a project command of the same name, so in a repository that still carries its own spells `/spell-*` runs the user tier's copy. Governance stays per repository — only spell delivery moves up a level.
234
+ Codex, VS Code Copilot and Claude Code each discover the user tier from their own home-directory locations (`~/.agents/skills` and `~/.claude/commands`), so no VS Code setting is needed. Governance stays per repository — only spell delivery moves up a level.
235
+
236
+ **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:
237
+
238
+ ```jsonc
239
+ // .arcane.json
240
+ { "spell_scope": "user" } // absent, or "repo", means this repository carries its own spells
241
+ ```
242
+
243
+ ```bash
244
+ spell update # lists the spell files this repository no longer manages — deletes nothing
245
+ spell update --prune # removes the untouched ones; anything you edited is kept and named
246
+ spell doctor # fails loudly if the user tier it now depends on is missing
247
+ ```
248
+
249
+ This is what removes the duplication: with the repository's own copies gone, one workspace holding ten Arcane projects shows one set of `/spell-*` entries, not ten. It is also what makes the answer predictable — while both tiers own a command name, which copy a client runs is not something you can read off the docs.
250
+
251
+ **Shared, team and cloud repositories should stay `repo`.** The field is committed and inherited by every clone, so opting in tells *everyone* who clones that repository to get their spells from a machine-wide install they may not have. Reverse it by setting the field back and running `spell update`, which reinstalls every spell file.
235
252
 
236
253
  ---
237
254
 
@@ -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.
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. 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
@@ -20,6 +20,7 @@ import {
20
20
  readdir,
21
21
  readFile,
22
22
  rm,
23
+ rmdir,
23
24
  writeFile
24
25
  } from "fs/promises";
25
26
  import { createHash } from "crypto";
@@ -89,6 +90,20 @@ async function removeWithin(targetDir, relativePath) {
89
90
  validateTargetPath(targetDir, relativePath);
90
91
  await rm(path.resolve(targetDir, relativePath), { force: true });
91
92
  }
93
+ async function removeEmptyAncestors(targetDir, relativePath) {
94
+ validateTargetPath(targetDir, relativePath);
95
+ const base = path.resolve(targetDir);
96
+ let dir = path.dirname(path.resolve(targetDir, relativePath));
97
+ while (dir !== base && dir.startsWith(base + path.sep)) {
98
+ try {
99
+ if ((await readdir(dir)).length > 0) return;
100
+ await rmdir(dir);
101
+ } catch {
102
+ return;
103
+ }
104
+ dir = path.dirname(dir);
105
+ }
106
+ }
92
107
  async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
93
108
  validateTargetPath(targetDir, relativeDir);
94
109
  const copiedFiles = [];
@@ -201,6 +216,9 @@ function validateTrackingFields(manifest, filePath) {
201
216
  if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
202
217
  throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
203
218
  }
219
+ if (manifest.spell_scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.spell_scope)) {
220
+ throw new ManifestInvalidFieldError(filePath, "spell_scope", manifest.spell_scope);
221
+ }
204
222
  if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
205
223
  throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
206
224
  }
@@ -866,7 +884,7 @@ function listProfiles() {
866
884
  }
867
885
 
868
886
  // src/modules/user-tier.ts
869
- import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir, writeFile as writeFile3 } from "fs/promises";
887
+ import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir as rmdir2, writeFile as writeFile3 } from "fs/promises";
870
888
  import { homedir } from "os";
871
889
  import { dirname, join, resolve } from "path";
872
890
 
@@ -999,6 +1017,14 @@ function componentForScope(component, scope) {
999
1017
  }
1000
1018
  return { name: component.name, description: component.description, files, sourceOverrides };
1001
1019
  }
1020
+ function componentForSpellScope(component, spellScope) {
1021
+ if (spellScope === "repo") return component;
1022
+ if (!SPELL_COMPONENT_NAMES.includes(component.name)) return component;
1023
+ return { ...component, files: [] };
1024
+ }
1025
+ function effectiveSpellScope(manifest) {
1026
+ return manifest.spell_scope ?? "repo";
1027
+ }
1002
1028
  function spellIdsInStore(components) {
1003
1029
  const ids = /* @__PURE__ */ new Set();
1004
1030
  for (const component of components) {
@@ -1076,7 +1102,7 @@ function perSpellDirectory(homeDir, relativePath) {
1076
1102
  async function removeDirectoryIfEmpty(dir) {
1077
1103
  try {
1078
1104
  const entries = await readdir2(dir);
1079
- if (entries.length === 0) await rmdir(dir);
1105
+ if (entries.length === 0) await rmdir2(dir);
1080
1106
  } catch {
1081
1107
  }
1082
1108
  }
@@ -3274,16 +3300,35 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3274
3300
  profile = await select2({
3275
3301
  message: "Select an installation profile:\n",
3276
3302
  choices: profiles.map((p) => {
3277
- const profileComponents = getProfile(p.id);
3303
+ const profileComponents2 = getProfile(p.id);
3278
3304
  return {
3279
3305
  value: p.id,
3280
3306
  name: `${PROFILE_ICONS[p.id] ?? "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
3281
- description: buildProfileDescription(profileComponents, p.id)
3307
+ description: buildProfileDescription(profileComponents2, p.id)
3282
3308
  };
3283
3309
  })
3284
3310
  });
3285
3311
  }
3286
- const components = getProfile(profile);
3312
+ const profileComponents = getProfile(profile);
3313
+ let spell_scope;
3314
+ if (!options.profile && !options.dryRun) {
3315
+ let storeVersion;
3316
+ try {
3317
+ storeVersion = (await readManifest(userTierRoot())).version;
3318
+ } catch {
3319
+ }
3320
+ if (storeVersion !== void 0) {
3321
+ console.log();
3322
+ const useTier = await confirm({
3323
+ message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
3324
+ default: false
3325
+ });
3326
+ spell_scope = useTier ? "user" : "repo";
3327
+ }
3328
+ }
3329
+ const components = profileComponents.map(
3330
+ (component) => componentForSpellScope(component, spell_scope ?? "repo")
3331
+ );
3287
3332
  const groups = categorizeComponents(components);
3288
3333
  console.log();
3289
3334
  printSection(`\u{1F4E6} Profile: ${profile}`);
@@ -3470,6 +3515,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3470
3515
  profile,
3471
3516
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
3472
3517
  components: installedComponents,
3518
+ ...spell_scope ? { spell_scope } : {},
3473
3519
  ...role ? { role } : {},
3474
3520
  ...tracking_mode ? { tracking_mode, external_provider } : {},
3475
3521
  ...subject_root !== void 0 ? { subject_root } : {},
@@ -4155,26 +4201,57 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
4155
4201
  return "reported";
4156
4202
  }
4157
4203
  await removeWithin(targetDir, file);
4204
+ await removeEmptyAncestors(targetDir, file);
4158
4205
  console.log(` Pruned orphaned file: ${file}`);
4159
4206
  return "pruned";
4160
4207
  }
4161
- async function findMissingTrackedFiles(targetDir, components, scope = "repo") {
4208
+ function findReadoptedSpellComponents(components, scope = "repo", spellScope = "repo") {
4209
+ if (scope !== "repo" || spellScope !== "repo") return /* @__PURE__ */ new Set();
4210
+ return new Set(
4211
+ components.filter((c) => SPELL_COMPONENT_NAMES.includes(c.name) && c.files.length === 0).map((c) => c.name)
4212
+ );
4213
+ }
4214
+ async function findMissingTrackedFiles(targetDir, components, scope = "repo", spellScope = "repo", readopted = /* @__PURE__ */ new Set()) {
4162
4215
  const missing = [];
4163
4216
  for (const installed of components) {
4164
4217
  let component;
4165
4218
  try {
4166
- component = componentForScope(getComponent(installed.name), scope);
4219
+ component = componentForSpellScope(
4220
+ componentForScope(getComponent(installed.name), scope),
4221
+ scope === "repo" ? spellScope : "repo"
4222
+ );
4167
4223
  } catch {
4168
4224
  continue;
4169
4225
  }
4170
4226
  if (component.initOnly || component.skipExisting) continue;
4171
- for (const file of installed.files) {
4227
+ const candidates = readopted.has(installed.name) ? component.files : installed.files;
4228
+ for (const file of candidates) {
4172
4229
  if (!component.files.includes(file)) continue;
4173
4230
  if (!await fileExists(join9(targetDir, file))) missing.push(file);
4174
4231
  }
4175
4232
  }
4176
4233
  return missing;
4177
4234
  }
4235
+ function findUnmanagedTrackedFiles(components, scope = "repo", spellScope = "repo") {
4236
+ const unmanaged = [];
4237
+ for (const installed of components) {
4238
+ let component;
4239
+ try {
4240
+ component = componentForSpellScope(
4241
+ componentForScope(getComponent(installed.name), scope),
4242
+ scope === "repo" ? spellScope : "repo"
4243
+ );
4244
+ } catch {
4245
+ unmanaged.push(...installed.files);
4246
+ continue;
4247
+ }
4248
+ const shipped = new Set(component.files);
4249
+ for (const file of installed.files) {
4250
+ if (!shipped.has(file)) unmanaged.push(file);
4251
+ }
4252
+ }
4253
+ return unmanaged;
4254
+ }
4178
4255
  async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4179
4256
  let manifest;
4180
4257
  try {
@@ -4196,6 +4273,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4196
4273
  return;
4197
4274
  }
4198
4275
  const scope = resolvedScope.scope;
4276
+ const spellScope = effectiveSpellScope(manifest);
4277
+ const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
4199
4278
  const homeDir = dirname4(targetDir);
4200
4279
  if (scope === "repo") {
4201
4280
  console.warn(
@@ -4229,8 +4308,9 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4229
4308
  }
4230
4309
  let sameVersionRestore = false;
4231
4310
  if (manifest.version === packageVersion && manifest.components.length > 0) {
4232
- const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
4233
- if (missing.length === 0) {
4311
+ const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
4312
+ const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
4313
+ if (missing.length === 0 && unmanaged.length === 0) {
4234
4314
  console.log("Already up to date.");
4235
4315
  if (scope === "user") {
4236
4316
  const fanout = await syncUserTierFanout({
@@ -4249,9 +4329,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4249
4329
  return;
4250
4330
  }
4251
4331
  sameVersionRestore = true;
4252
- console.log(
4253
- `${options.dryRun ? "[dry-run] " : ""}Already at v${packageVersion}, but ${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing \u2014 restoring.`
4254
- );
4332
+ const prefix = options.dryRun ? "[dry-run] " : "";
4333
+ if (missing.length > 0) {
4334
+ console.log(
4335
+ `${prefix}Already at v${packageVersion}, but ${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing \u2014 restoring.`
4336
+ );
4337
+ } else {
4338
+ console.log(
4339
+ `${prefix}Already at v${packageVersion}, but ${unmanaged.length} tracked file${unmanaged.length === 1 ? " is" : "s are"} no longer managed here \u2014 reviewing.`
4340
+ );
4341
+ }
4255
4342
  }
4256
4343
  if (manifest.components.length === 0) {
4257
4344
  console.log("No components installed. Nothing to update.");
@@ -4266,7 +4353,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4266
4353
  for (const installed of componentsToUpdate) {
4267
4354
  let component;
4268
4355
  try {
4269
- component = componentForScope(getComponent(installed.name), scope);
4356
+ component = componentForSpellScope(
4357
+ componentForScope(getComponent(installed.name), scope),
4358
+ scope === "repo" ? spellScope : "repo"
4359
+ );
4270
4360
  } catch (err) {
4271
4361
  if (err instanceof ComponentNotFoundError) {
4272
4362
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
@@ -4301,7 +4391,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4301
4391
  continue;
4302
4392
  }
4303
4393
  if (sameVersionRestore) {
4304
- const tracked = installed.files.includes(file);
4394
+ const tracked = installed.files.includes(file) || readopted.has(installed.name);
4305
4395
  const restorable = tracked && !targetExists && !component.skipExisting;
4306
4396
  if (!restorable) {
4307
4397
  if (tracked) {
@@ -4378,13 +4468,24 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4378
4468
  }
4379
4469
  for (const file of installed.files) {
4380
4470
  if (updatedFiles.includes(file)) continue;
4471
+ const recordedHash = installed.fileHashes?.[file];
4381
4472
  const status = await resolveOrphan(
4382
4473
  targetDir,
4383
4474
  file,
4384
- installed.fileHashes?.[file],
4475
+ recordedHash,
4385
4476
  Boolean(options.prune) && !options.dryRun
4386
4477
  );
4387
4478
  orphanReport.push({ file, status });
4479
+ if (status === "reported") {
4480
+ try {
4481
+ validateTargetPath(targetDir, file);
4482
+ if (await fileExists(join9(targetDir, file))) {
4483
+ updatedFiles.push(file);
4484
+ if (recordedHash !== void 0) fileHashes[file] = recordedHash;
4485
+ }
4486
+ } catch {
4487
+ }
4488
+ }
4388
4489
  }
4389
4490
  updatedComponents.push({
4390
4491
  ...installed,
@@ -4404,6 +4505,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4404
4505
  const marker = status === "pruned" ? "pruned" : options.prune ? "kept (see reason above)" : "not removed";
4405
4506
  console.log(` ${file} \u2014 ${marker}`);
4406
4507
  }
4508
+ if (scope === "repo" && spellScope === "user") {
4509
+ console.log(
4510
+ ' This repository takes its spells from the user tier (spell_scope: "user"), so the files above are no longer managed here \u2014 every client reads ~/.arcane instead.'
4511
+ );
4512
+ }
4407
4513
  if (!options.prune) {
4408
4514
  console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
4409
4515
  }
@@ -4670,12 +4776,18 @@ async function runStatus(targetDir, packageVersion, options = {}) {
4670
4776
  );
4671
4777
  }
4672
4778
  } else {
4673
- console.log(" Scope: repo");
4779
+ const spellScope = effectiveSpellScope(manifest);
4780
+ console.log(spellScope === "user" ? " Scope: repo (spells: user tier)" : " Scope: repo");
4781
+ const store = userTierRoot();
4674
4782
  try {
4675
- const store = userTierRoot();
4676
4783
  const userManifest = await readManifest(store);
4677
4784
  console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
4678
4785
  } catch {
4786
+ if (spellScope === "user") {
4787
+ console.log(
4788
+ ` ! User tier: not installed at ${store} \u2014 this repository expects it for every spell. Run \`spell init --user\` (\`spell doctor\` explains).`
4789
+ );
4790
+ }
4679
4791
  }
4680
4792
  }
4681
4793
  console.log("");
@@ -4881,7 +4993,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
4881
4993
  }
4882
4994
 
4883
4995
  // src/commands/doctor.ts
4884
- import { access as access4, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
4996
+ import { access as access4, readdir as readdir7, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
4885
4997
  import { join as join13, dirname as dirname8 } from "path";
4886
4998
  import { execFile as execFile3 } from "child_process";
4887
4999
  import { promisify as promisify2 } from "util";
@@ -5221,8 +5333,8 @@ async function checkVSCodeExtension(id, label) {
5221
5333
  ];
5222
5334
  for (const dir of extDirs) {
5223
5335
  try {
5224
- const { readdir: readdir9 } = await import("fs/promises");
5225
- const entries = await readdir9(dir);
5336
+ const { readdir: readdir10 } = await import("fs/promises");
5337
+ const entries = await readdir10(dir);
5226
5338
  const prefix = id.toLowerCase() + "-";
5227
5339
  const found = entries.some(
5228
5340
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -5646,6 +5758,66 @@ async function checkPlatformBranchPolicy(targetDir) {
5646
5758
  message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
5647
5759
  };
5648
5760
  }
5761
+ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
5762
+ const name = "Spell scope (ARC-045)";
5763
+ let manifest;
5764
+ try {
5765
+ manifest = await readManifest(targetDir);
5766
+ } catch {
5767
+ return { name, passed: true, blocking: false, message: "no .arcane.json \u2014 nothing to check" };
5768
+ }
5769
+ if (effectiveSpellScope(manifest) === "repo") {
5770
+ return {
5771
+ name,
5772
+ passed: true,
5773
+ blocking: false,
5774
+ message: "repo \u2014 this repository carries its own spells (the default)"
5775
+ };
5776
+ }
5777
+ const storeRoot = userTierRoot(homeDir);
5778
+ let store;
5779
+ try {
5780
+ store = await readManifest(storeRoot);
5781
+ } catch (err) {
5782
+ return {
5783
+ name,
5784
+ passed: false,
5785
+ message: `this repository takes its spells from the user tier (spell_scope: "user"), but ${storeRoot} ${err instanceof ManifestNotFoundError ? "is not installed" : `could not be read (${err instanceof Error ? err.message : String(err)})`} \u2014 no client can reach a spell here. Run \`spell init --user\`, or set spell_scope back to "repo" and run \`spell update\`.`
5786
+ };
5787
+ }
5788
+ let spellCount = 0;
5789
+ let probed = false;
5790
+ const probe = "spell-status.md";
5791
+ try {
5792
+ const entries = await readdir7(join13(storeRoot, USER_TIER_SPELLS_DIR));
5793
+ const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
5794
+ spellCount = spells.length;
5795
+ probed = spells.includes(probe);
5796
+ } catch {
5797
+ }
5798
+ if (spellCount === 0 || !probed) {
5799
+ return {
5800
+ name,
5801
+ passed: false,
5802
+ message: `the user tier at ${storeRoot} holds ${spellCount} spell(s)${spellCount > 0 && !probed ? ` and not ${probe}` : ""} \u2014 this repository expects it to supply every spell. Run \`spell update --user\`.`
5803
+ };
5804
+ }
5805
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
5806
+ if (typeof manifest.version === "string" && majorMinor(store.version) !== majorMinor(manifest.version)) {
5807
+ return {
5808
+ name,
5809
+ passed: false,
5810
+ blocking: false,
5811
+ message: `this repository is at v${manifest.version} but the user tier it uses is at v${store.version} \u2014 run \`spell update --user\` (and \`spell update\` here) to bring them together.`
5812
+ };
5813
+ }
5814
+ return {
5815
+ name,
5816
+ passed: true,
5817
+ blocking: false,
5818
+ message: `user \u2014 ${spellCount} spell(s) from ${storeRoot} (v${store.version}), ${probe} present`
5819
+ };
5820
+ }
5649
5821
  async function runDoctor(targetDir, options = {}, assetsDir) {
5650
5822
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
5651
5823
  if (options.leaks) {
@@ -5671,7 +5843,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
5671
5843
  checkPlatformBranchPolicy(targetDir),
5672
5844
  checkDelegations(targetDir),
5673
5845
  checkMcpConfig(targetDir),
5674
- checkUserTier(resolveHomeDir(), options.packageVersion)
5846
+ checkUserTier(resolveHomeDir(), options.packageVersion),
5847
+ checkSpellScope(targetDir)
5675
5848
  ]);
5676
5849
  const sessionResults = await checkSessionContinuity(targetDir);
5677
5850
  results.push(...sessionResults);
@@ -5719,7 +5892,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
5719
5892
  import { readFile as readFile12 } from "fs/promises";
5720
5893
 
5721
5894
  // src/modules/ward.ts
5722
- import { readdir as readdir7, readFile as readFile11 } from "fs/promises";
5895
+ import { readdir as readdir8, readFile as readFile11 } from "fs/promises";
5723
5896
  import { join as join14, relative as relative2 } from "path";
5724
5897
  async function scanFileAllRules(absolutePath, displayPath, rules) {
5725
5898
  const findings = [];
@@ -5778,7 +5951,7 @@ async function scanFilenames(root, rules, repoRoot = root) {
5778
5951
  const findings = [];
5779
5952
  let entries;
5780
5953
  try {
5781
- entries = await readdir7(root, { withFileTypes: true });
5954
+ entries = await readdir8(root, { withFileTypes: true });
5782
5955
  } catch {
5783
5956
  return findings;
5784
5957
  }
@@ -6000,7 +6173,7 @@ import { access as access5 } from "fs/promises";
6000
6173
  import { join as join17 } from "path";
6001
6174
 
6002
6175
  // src/modules/show-report/generate.ts
6003
- import { mkdir as mkdir8, readdir as readdir8, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
6176
+ import { mkdir as mkdir8, readdir as readdir9, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
6004
6177
  import { basename as basename3, dirname as dirname9, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
6005
6178
 
6006
6179
  // src/modules/show-report/model.ts
@@ -6567,7 +6740,7 @@ async function discoverPrograms(rootDir) {
6567
6740
  const plansDir = join16(rootDir, "docs", "plans");
6568
6741
  let entries;
6569
6742
  try {
6570
- entries = (await readdir8(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6743
+ entries = (await readdir9(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6571
6744
  } catch (error) {
6572
6745
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
6573
6746
  throw error;