arcane-cli 1.1.0 → 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/dist/index.js CHANGED
@@ -19,13 +19,38 @@ import {
19
19
  access,
20
20
  readdir,
21
21
  readFile,
22
+ rm,
23
+ rmdir,
22
24
  writeFile
23
25
  } from "fs/promises";
24
26
  import { createHash } from "crypto";
25
27
  import path from "path";
28
+ function normalizeLineEndings(content) {
29
+ if (!content.includes(13)) return content;
30
+ const out = Buffer.allocUnsafe(content.length);
31
+ let o = 0;
32
+ for (let i = 0; i < content.length; i++) {
33
+ const byte = content[i];
34
+ if (byte === 13) {
35
+ out[o++] = 10;
36
+ if (content[i + 1] === 10) i++;
37
+ continue;
38
+ }
39
+ out[o++] = byte;
40
+ }
41
+ return out.subarray(0, o);
42
+ }
43
+ function hashContent(content) {
44
+ const bytes = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
45
+ return createHash("sha256").update(normalizeLineEndings(bytes)).digest("hex");
46
+ }
26
47
  async function hashFile(filePath) {
48
+ return hashContent(await readFile(filePath));
49
+ }
50
+ async function fileMatchesHash(filePath, recordedHash) {
27
51
  const content = await readFile(filePath);
28
- return createHash("sha256").update(content).digest("hex");
52
+ if (hashContent(content) === recordedHash) return true;
53
+ return createHash("sha256").update(content).digest("hex") === recordedHash;
29
54
  }
30
55
  function validateTargetPath(targetDir, relativePath) {
31
56
  const resolvedBase = path.resolve(targetDir);
@@ -57,10 +82,28 @@ async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
57
82
  );
58
83
  }
59
84
  const content = await readFile(srcPath);
60
- const hash = createHash("sha256").update(content).digest("hex");
85
+ const hash = hashContent(content);
61
86
  await writeFile(destPath, content);
62
87
  return hash;
63
88
  }
89
+ async function removeWithin(targetDir, relativePath) {
90
+ validateTargetPath(targetDir, relativePath);
91
+ await rm(path.resolve(targetDir, relativePath), { force: true });
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
+ }
64
107
  async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
65
108
  validateTargetPath(targetDir, relativeDir);
66
109
  const copiedFiles = [];
@@ -173,6 +216,9 @@ function validateTrackingFields(manifest, filePath) {
173
216
  if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
174
217
  throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
175
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
+ }
176
222
  if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
177
223
  throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
178
224
  }
@@ -838,8 +884,7 @@ function listProfiles() {
838
884
  }
839
885
 
840
886
  // src/modules/user-tier.ts
841
- import { createHash as createHash2 } from "crypto";
842
- import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm, 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";
843
888
  import { homedir } from "os";
844
889
  import { dirname, join, resolve } from "path";
845
890
 
@@ -902,6 +947,7 @@ function renderClaudeCommandStub(id, frontmatter, canonicalRef = canonicalSpellP
902
947
  const title = deriveStubTitle(frontmatter.name);
903
948
  const description = frontmatter.claudeDescription ?? frontmatter.description;
904
949
  const promptPath = canonicalRef;
950
+ const includeRef = /\s/.test(promptPath) ? `"${promptPath}"` : promptPath;
905
951
  return `---
906
952
  description: ${description}
907
953
  ---
@@ -914,7 +960,7 @@ See the full prompt at \`${promptPath}\` for the complete workflow definition.
914
960
 
915
961
  ---
916
962
 
917
- @${promptPath}
963
+ @${includeRef}
918
964
  `;
919
965
  }
920
966
  var SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
@@ -971,6 +1017,14 @@ function componentForScope(component, scope) {
971
1017
  }
972
1018
  return { name: component.name, description: component.description, files, sourceOverrides };
973
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
+ }
974
1028
  function spellIdsInStore(components) {
975
1029
  const ids = /* @__PURE__ */ new Set();
976
1030
  for (const component of components) {
@@ -1041,9 +1095,6 @@ async function classifyTarget(path4) {
1041
1095
  throw err;
1042
1096
  }
1043
1097
  }
1044
- function sha256(content) {
1045
- return createHash2("sha256").update(content, "utf-8").digest("hex");
1046
- }
1047
1098
  function perSpellDirectory(homeDir, relativePath) {
1048
1099
  if (clientOfFanoutPath(relativePath) !== "codex") return void 0;
1049
1100
  return dirname(join(homeDir, relativePath));
@@ -1051,7 +1102,7 @@ function perSpellDirectory(homeDir, relativePath) {
1051
1102
  async function removeDirectoryIfEmpty(dir) {
1052
1103
  try {
1053
1104
  const entries = await readdir2(dir);
1054
- if (entries.length === 0) await rmdir(dir);
1105
+ if (entries.length === 0) await rmdir2(dir);
1055
1106
  } catch {
1056
1107
  }
1057
1108
  }
@@ -1072,9 +1123,10 @@ async function syncUserTierFanout(options) {
1072
1123
  }
1073
1124
  }
1074
1125
  for (const file of desired) {
1126
+ validateTargetPath(options.homeDir, file.relativePath);
1075
1127
  const target = join(options.homeDir, file.relativePath);
1076
1128
  const recorded = previous[file.relativePath];
1077
- const newHash = sha256(file.content);
1129
+ const newHash = hashContent(file.content);
1078
1130
  const state = await classifyTarget(target);
1079
1131
  if (state === "absent") {
1080
1132
  if (!dryRun) {
@@ -1089,12 +1141,12 @@ async function syncUserTierFanout(options) {
1089
1141
  outcomes.push({ relativePath: file.relativePath, status: "collision" });
1090
1142
  continue;
1091
1143
  }
1092
- const currentHash = await hashFile(target);
1093
- if (currentHash !== recorded) {
1144
+ if (!await fileMatchesHash(target, recorded)) {
1094
1145
  record[file.relativePath] = recorded;
1095
1146
  outcomes.push({ relativePath: file.relativePath, status: "customized" });
1096
1147
  continue;
1097
1148
  }
1149
+ const currentHash = await hashFile(target);
1098
1150
  if (currentHash === newHash) {
1099
1151
  record[file.relativePath] = newHash;
1100
1152
  outcomes.push({ relativePath: file.relativePath, status: "unchanged" });
@@ -1106,19 +1158,20 @@ async function syncUserTierFanout(options) {
1106
1158
  }
1107
1159
  for (const [relativePath, recorded] of Object.entries(previous)) {
1108
1160
  if (desiredPaths.has(relativePath)) continue;
1161
+ validateTargetPath(options.homeDir, relativePath);
1109
1162
  const target = join(options.homeDir, relativePath);
1110
1163
  const state = await classifyTarget(target);
1111
1164
  if (state === "absent") {
1112
1165
  outcomes.push({ relativePath, status: "gone" });
1113
1166
  continue;
1114
1167
  }
1115
- if (state === "other" || await hashFile(target) !== recorded) {
1168
+ if (state === "other" || !await fileMatchesHash(target, recorded)) {
1116
1169
  record[relativePath] = recorded;
1117
1170
  outcomes.push({ relativePath, status: "kept-edited" });
1118
1171
  continue;
1119
1172
  }
1120
1173
  if (!dryRun) {
1121
- await rm(target, { force: true });
1174
+ await rm2(target, { force: true });
1122
1175
  const spellDir = perSpellDirectory(options.homeDir, relativePath);
1123
1176
  if (spellDir !== void 0) await removeDirectoryIfEmpty(spellDir);
1124
1177
  }
@@ -1209,7 +1262,7 @@ async function inspectUserTierFanout(homeDir, record) {
1209
1262
  health.missing.push(relativePath);
1210
1263
  continue;
1211
1264
  }
1212
- if (state === "other" || await hashFile(target) !== recorded) health.customized.push(relativePath);
1265
+ if (state === "other" || !await fileMatchesHash(target, recorded)) health.customized.push(relativePath);
1213
1266
  }
1214
1267
  return health;
1215
1268
  }
@@ -1834,7 +1887,7 @@ import { join as join3 } from "path";
1834
1887
  import { homedir as homedir2 } from "os";
1835
1888
 
1836
1889
  // src/modules/merger.ts
1837
- import { readFile as readFile5, writeFile as writeFile4, rm as rm2 } from "fs/promises";
1890
+ import { readFile as readFile5, writeFile as writeFile4, rm as rm3 } from "fs/promises";
1838
1891
  import path3 from "path";
1839
1892
  var MARKER_START = "<!-- arcane:start -->";
1840
1893
  var MARKER_END = "<!-- arcane:end -->";
@@ -1920,7 +1973,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1920
1973
  const stripped = (before + after).replace(/\n{3,}/g, "\n\n").trim();
1921
1974
  if (!opts.dryRun) {
1922
1975
  if (stripped.length === 0) {
1923
- await rm2(destPath, { force: true });
1976
+ await rm3(destPath, { force: true });
1924
1977
  } else {
1925
1978
  await writeFile4(destPath, stripped + "\n", "utf8");
1926
1979
  }
@@ -2426,7 +2479,7 @@ async function runAgentsList(targetDir) {
2426
2479
  }
2427
2480
 
2428
2481
  // src/modules/push-safety.ts
2429
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm3, readdir as readdir4 } from "fs/promises";
2482
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir4 } from "fs/promises";
2430
2483
  import { constants } from "fs";
2431
2484
  import { join as join5, resolve as resolve2, dirname as dirname2, basename } from "path";
2432
2485
 
@@ -2843,7 +2896,7 @@ async function removeHook(cwd, hookName) {
2843
2896
  if (!isArcaneHooksPath(root, existing.value)) return;
2844
2897
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2845
2898
  try {
2846
- await rm3(await hookFilePath(cwd, hookName), { force: true });
2899
+ await rm4(await hookFilePath(cwd, hookName), { force: true });
2847
2900
  } catch {
2848
2901
  }
2849
2902
  const hooksDir = join5(root, ARCANE_HOOKS_DIR);
@@ -3247,16 +3300,35 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3247
3300
  profile = await select2({
3248
3301
  message: "Select an installation profile:\n",
3249
3302
  choices: profiles.map((p) => {
3250
- const profileComponents = getProfile(p.id);
3303
+ const profileComponents2 = getProfile(p.id);
3251
3304
  return {
3252
3305
  value: p.id,
3253
3306
  name: `${PROFILE_ICONS[p.id] ?? "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
3254
- description: buildProfileDescription(profileComponents, p.id)
3307
+ description: buildProfileDescription(profileComponents2, p.id)
3255
3308
  };
3256
3309
  })
3257
3310
  });
3258
3311
  }
3259
- 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
+ );
3260
3332
  const groups = categorizeComponents(components);
3261
3333
  console.log();
3262
3334
  printSection(`\u{1F4E6} Profile: ${profile}`);
@@ -3443,6 +3515,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3443
3515
  profile,
3444
3516
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
3445
3517
  components: installedComponents,
3518
+ ...spell_scope ? { spell_scope } : {},
3446
3519
  ...role ? { role } : {},
3447
3520
  ...tracking_mode ? { tracking_mode, external_provider } : {},
3448
3521
  ...subject_root !== void 0 ? { subject_root } : {},
@@ -3757,7 +3830,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3757
3830
 
3758
3831
  // src/commands/update.ts
3759
3832
  import { dirname as dirname4, join as join9 } from "path";
3760
- import { readFile as readFile7, writeFile as writeFile9, rm as rm4 } from "fs/promises";
3833
+ import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
3761
3834
 
3762
3835
  // src/modules/hub.ts
3763
3836
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
@@ -4110,6 +4183,12 @@ function migrateLegacyComponents(components) {
4110
4183
  return out;
4111
4184
  }
4112
4185
  async function resolveOrphan(targetDir, file, recordedHash, prune) {
4186
+ try {
4187
+ validateTargetPath(targetDir, file);
4188
+ } catch {
4189
+ console.log(` ! Refused (path escapes the target directory): ${file}`);
4190
+ return "reported";
4191
+ }
4113
4192
  const filePath = join9(targetDir, file);
4114
4193
  if (!await fileExists(filePath)) return "not-found";
4115
4194
  if (!prune) return "reported";
@@ -4117,32 +4196,62 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
4117
4196
  console.log(` ! Orphaned, not pruned (no recorded hash to verify it's untouched): ${file}`);
4118
4197
  return "reported";
4119
4198
  }
4120
- const currentHash = await hashFile(filePath);
4121
- if (currentHash !== recordedHash) {
4199
+ if (!await fileMatchesHash(filePath, recordedHash)) {
4122
4200
  console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
4123
4201
  return "reported";
4124
4202
  }
4125
- await rm4(filePath, { force: true });
4203
+ await removeWithin(targetDir, file);
4204
+ await removeEmptyAncestors(targetDir, file);
4126
4205
  console.log(` Pruned orphaned file: ${file}`);
4127
4206
  return "pruned";
4128
4207
  }
4129
- 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()) {
4130
4215
  const missing = [];
4131
4216
  for (const installed of components) {
4132
4217
  let component;
4133
4218
  try {
4134
- component = componentForScope(getComponent(installed.name), scope);
4219
+ component = componentForSpellScope(
4220
+ componentForScope(getComponent(installed.name), scope),
4221
+ scope === "repo" ? spellScope : "repo"
4222
+ );
4135
4223
  } catch {
4136
4224
  continue;
4137
4225
  }
4138
4226
  if (component.initOnly || component.skipExisting) continue;
4139
- for (const file of installed.files) {
4227
+ const candidates = readopted.has(installed.name) ? component.files : installed.files;
4228
+ for (const file of candidates) {
4140
4229
  if (!component.files.includes(file)) continue;
4141
4230
  if (!await fileExists(join9(targetDir, file))) missing.push(file);
4142
4231
  }
4143
4232
  }
4144
4233
  return missing;
4145
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
+ }
4146
4255
  async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4147
4256
  let manifest;
4148
4257
  try {
@@ -4164,6 +4273,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4164
4273
  return;
4165
4274
  }
4166
4275
  const scope = resolvedScope.scope;
4276
+ const spellScope = effectiveSpellScope(manifest);
4277
+ const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
4167
4278
  const homeDir = dirname4(targetDir);
4168
4279
  if (scope === "repo") {
4169
4280
  console.warn(
@@ -4197,8 +4308,9 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4197
4308
  }
4198
4309
  let sameVersionRestore = false;
4199
4310
  if (manifest.version === packageVersion && manifest.components.length > 0) {
4200
- const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
4201
- 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) {
4202
4314
  console.log("Already up to date.");
4203
4315
  if (scope === "user") {
4204
4316
  const fanout = await syncUserTierFanout({
@@ -4217,9 +4329,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4217
4329
  return;
4218
4330
  }
4219
4331
  sameVersionRestore = true;
4220
- console.log(
4221
- `${options.dryRun ? "[dry-run] " : ""}Already at v${packageVersion}, but ${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing \u2014 restoring.`
4222
- );
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
+ }
4223
4342
  }
4224
4343
  if (manifest.components.length === 0) {
4225
4344
  console.log("No components installed. Nothing to update.");
@@ -4234,7 +4353,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4234
4353
  for (const installed of componentsToUpdate) {
4235
4354
  let component;
4236
4355
  try {
4237
- component = componentForScope(getComponent(installed.name), scope);
4356
+ component = componentForSpellScope(
4357
+ componentForScope(getComponent(installed.name), scope),
4358
+ scope === "repo" ? spellScope : "repo"
4359
+ );
4238
4360
  } catch (err) {
4239
4361
  if (err instanceof ComponentNotFoundError) {
4240
4362
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
@@ -4269,7 +4391,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4269
4391
  continue;
4270
4392
  }
4271
4393
  if (sameVersionRestore) {
4272
- const tracked = installed.files.includes(file);
4394
+ const tracked = installed.files.includes(file) || readopted.has(installed.name);
4273
4395
  const restorable = tracked && !targetExists && !component.skipExisting;
4274
4396
  if (!restorable) {
4275
4397
  if (tracked) {
@@ -4279,7 +4401,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4279
4401
  continue;
4280
4402
  }
4281
4403
  }
4282
- const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && await hashFile(join9(targetDir, file)) !== recordedHash;
4404
+ const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join9(targetDir, file), recordedHash);
4283
4405
  if (preserveExisting) {
4284
4406
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
4285
4407
  } else if (editedByOperator && isClientShimPath(file)) {
@@ -4346,13 +4468,24 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4346
4468
  }
4347
4469
  for (const file of installed.files) {
4348
4470
  if (updatedFiles.includes(file)) continue;
4471
+ const recordedHash = installed.fileHashes?.[file];
4349
4472
  const status = await resolveOrphan(
4350
4473
  targetDir,
4351
4474
  file,
4352
- installed.fileHashes?.[file],
4475
+ recordedHash,
4353
4476
  Boolean(options.prune) && !options.dryRun
4354
4477
  );
4355
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
+ }
4356
4489
  }
4357
4490
  updatedComponents.push({
4358
4491
  ...installed,
@@ -4372,6 +4505,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4372
4505
  const marker = status === "pruned" ? "pruned" : options.prune ? "kept (see reason above)" : "not removed";
4373
4506
  console.log(` ${file} \u2014 ${marker}`);
4374
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
+ }
4375
4513
  if (!options.prune) {
4376
4514
  console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
4377
4515
  }
@@ -4638,12 +4776,18 @@ async function runStatus(targetDir, packageVersion, options = {}) {
4638
4776
  );
4639
4777
  }
4640
4778
  } else {
4641
- 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();
4642
4782
  try {
4643
- const store = userTierRoot();
4644
4783
  const userManifest = await readManifest(store);
4645
4784
  console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
4646
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
+ }
4647
4791
  }
4648
4792
  }
4649
4793
  console.log("");
@@ -4755,11 +4899,13 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4755
4899
  let removed = 0;
4756
4900
  for (const component of manifest.components) {
4757
4901
  for (const file of component.files) {
4758
- const filePath = join11(targetDir, file);
4759
4902
  try {
4760
- await rm5(filePath, { force: true });
4903
+ await removeWithin(targetDir, file);
4761
4904
  removed++;
4762
- } catch {
4905
+ } catch (err) {
4906
+ console.log(
4907
+ ` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`
4908
+ );
4763
4909
  }
4764
4910
  }
4765
4911
  }
@@ -4827,8 +4973,12 @@ async function uninstallUserTier(options, storeRoot, manifest) {
4827
4973
  });
4828
4974
  let removed = 0;
4829
4975
  for (const file of storeFiles) {
4830
- await rm5(join11(storeRoot, file), { force: true });
4831
- removed++;
4976
+ try {
4977
+ await removeWithin(storeRoot, file);
4978
+ removed++;
4979
+ } catch (err) {
4980
+ console.log(` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
4981
+ }
4832
4982
  }
4833
4983
  await rm5(join11(storeRoot, ".arcane.json"), { force: true });
4834
4984
  await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
@@ -4843,7 +4993,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
4843
4993
  }
4844
4994
 
4845
4995
  // src/commands/doctor.ts
4846
- 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";
4847
4997
  import { join as join13, dirname as dirname8 } from "path";
4848
4998
  import { execFile as execFile3 } from "child_process";
4849
4999
  import { promisify as promisify2 } from "util";
@@ -5183,8 +5333,8 @@ async function checkVSCodeExtension(id, label) {
5183
5333
  ];
5184
5334
  for (const dir of extDirs) {
5185
5335
  try {
5186
- const { readdir: readdir9 } = await import("fs/promises");
5187
- const entries = await readdir9(dir);
5336
+ const { readdir: readdir10 } = await import("fs/promises");
5337
+ const entries = await readdir10(dir);
5188
5338
  const prefix = id.toLowerCase() + "-";
5189
5339
  const found = entries.some(
5190
5340
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -5608,6 +5758,66 @@ async function checkPlatformBranchPolicy(targetDir) {
5608
5758
  message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
5609
5759
  };
5610
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
+ }
5611
5821
  async function runDoctor(targetDir, options = {}, assetsDir) {
5612
5822
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
5613
5823
  if (options.leaks) {
@@ -5633,7 +5843,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
5633
5843
  checkPlatformBranchPolicy(targetDir),
5634
5844
  checkDelegations(targetDir),
5635
5845
  checkMcpConfig(targetDir),
5636
- checkUserTier(resolveHomeDir(), options.packageVersion)
5846
+ checkUserTier(resolveHomeDir(), options.packageVersion),
5847
+ checkSpellScope(targetDir)
5637
5848
  ]);
5638
5849
  const sessionResults = await checkSessionContinuity(targetDir);
5639
5850
  results.push(...sessionResults);
@@ -5681,7 +5892,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
5681
5892
  import { readFile as readFile12 } from "fs/promises";
5682
5893
 
5683
5894
  // src/modules/ward.ts
5684
- import { readdir as readdir7, readFile as readFile11 } from "fs/promises";
5895
+ import { readdir as readdir8, readFile as readFile11 } from "fs/promises";
5685
5896
  import { join as join14, relative as relative2 } from "path";
5686
5897
  async function scanFileAllRules(absolutePath, displayPath, rules) {
5687
5898
  const findings = [];
@@ -5740,7 +5951,7 @@ async function scanFilenames(root, rules, repoRoot = root) {
5740
5951
  const findings = [];
5741
5952
  let entries;
5742
5953
  try {
5743
- entries = await readdir7(root, { withFileTypes: true });
5954
+ entries = await readdir8(root, { withFileTypes: true });
5744
5955
  } catch {
5745
5956
  return findings;
5746
5957
  }
@@ -5962,7 +6173,7 @@ import { access as access5 } from "fs/promises";
5962
6173
  import { join as join17 } from "path";
5963
6174
 
5964
6175
  // src/modules/show-report/generate.ts
5965
- 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";
5966
6177
  import { basename as basename3, dirname as dirname9, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
5967
6178
 
5968
6179
  // src/modules/show-report/model.ts
@@ -6199,9 +6410,10 @@ async function getVersionAtRef(cwd, ref) {
6199
6410
  return null;
6200
6411
  }
6201
6412
  }
6202
- async function getCloseCommit(cwd, completedDate) {
6413
+ async function getCloseCommit(cwd, completedDate, generatedOutputs = []) {
6203
6414
  if (!completedDate) {
6204
- const head = await runGitTextOrNull(cwd, ["rev-parse", "HEAD"]);
6415
+ const args = generatedOutputs.length === 0 ? ["rev-parse", "HEAD"] : ["log", "-1", "--format=%H", "HEAD", "--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)];
6416
+ const head = await runGitTextOrNull(cwd, args);
6205
6417
  return head === null || head === "" ? null : head;
6206
6418
  }
6207
6419
  const log = await runGitTextOrNull(cwd, ["log", "--format=%H%x09%cs", "HEAD"]);
@@ -6212,13 +6424,14 @@ async function getCloseCommit(cwd, completedDate) {
6212
6424
  }
6213
6425
  return null;
6214
6426
  }
6215
- async function getCast(cwd, fromRef, toRef) {
6427
+ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
6216
6428
  const ETX = "";
6217
6429
  const log = await runGitTextOrNull(cwd, [
6218
6430
  "log",
6219
6431
  "--no-merges",
6220
6432
  `--format=%B${ETX}`,
6221
- `${fromRef}..${toRef}`
6433
+ `${fromRef}..${toRef}`,
6434
+ ...generatedOutputs.length > 0 ? ["--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)] : []
6222
6435
  ]);
6223
6436
  const cast = /* @__PURE__ */ new Map();
6224
6437
  if (log === null || log === "") return cast;
@@ -6357,12 +6570,18 @@ async function buildShowReportModel(options) {
6357
6570
  const cast = /* @__PURE__ */ new Map();
6358
6571
  if (frontmatter.baseline) {
6359
6572
  const baselineSha = extractSha(frontmatter.baseline);
6360
- const closeSha = await getCloseCommit(options.rootDir, frontmatter.completed);
6573
+ const reportDir = options.planRelPath.split("/").slice(0, -1).join("/");
6574
+ const generatedOutputs = ["show-report.json", "show-report.html"].map(
6575
+ (f) => reportDir ? `${reportDir}/${f}` : f
6576
+ );
6577
+ const closeSha = await getCloseCommit(options.rootDir, frontmatter.completed, generatedOutputs);
6361
6578
  if (closeSha) {
6362
6579
  const [from, to, castMap] = await Promise.all([
6363
6580
  getVersionAtRef(options.rootDir, baselineSha),
6364
6581
  getVersionAtRef(options.rootDir, closeSha),
6365
- getCast(options.rootDir, baselineSha, closeSha)
6582
+ // Regeneration-only commits are never part of the cast, for a closed
6583
+ // program as much as an active one.
6584
+ getCast(options.rootDir, baselineSha, closeSha, generatedOutputs)
6366
6585
  ]);
6367
6586
  if (from && to) versionSpan = { from, to };
6368
6587
  for (const [name, count] of castMap) cast.set(name, count);
@@ -6521,7 +6740,7 @@ async function discoverPrograms(rootDir) {
6521
6740
  const plansDir = join16(rootDir, "docs", "plans");
6522
6741
  let entries;
6523
6742
  try {
6524
- entries = (await readdir8(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6743
+ entries = (await readdir9(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6525
6744
  } catch (error) {
6526
6745
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
6527
6746
  throw error;
@@ -6540,7 +6759,7 @@ async function programFromPlanPath(rootDir, planPath) {
6540
6759
  const planRelPath = toPosix(relative3(rootDir, planAbs));
6541
6760
  return programFromPlan(rootDir, basename3(dirname9(planAbs)), planRelPath);
6542
6761
  }
6543
- function normalizeLineEndings(content) {
6762
+ function normalizeLineEndings2(content) {
6544
6763
  return content.replace(/\r\n?/g, "\n");
6545
6764
  }
6546
6765
  async function readOptionalFile(path4) {
@@ -6586,7 +6805,7 @@ async function runReportGeneration(options) {
6586
6805
  ];
6587
6806
  for (const [relPath, expected] of outputs) {
6588
6807
  const actual = await readOptionalFile(join16(options.rootDir, relPath));
6589
- if (actual === null || normalizeLineEndings(actual) !== normalizeLineEndings(expected)) {
6808
+ if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
6590
6809
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
6591
6810
  if (options.mode === "write") {
6592
6811
  await mkdir8(outAbs, { recursive: true });