arcane-cli 1.6.0 → 1.7.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
@@ -6,7 +6,7 @@ const require = __createRequire(import.meta.url);
6
6
  import { Command } from "commander";
7
7
  import { createRequire as createRequire2 } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { dirname as dirname11, join as join19 } from "path";
9
+ import { dirname as dirname11, join as join20 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
12
  import { select as select3, confirm as confirm2, input as input3 } from "@inquirer/prompts";
@@ -322,7 +322,8 @@ var PROFILE_CONFIGS = [
322
322
  "git-conventions",
323
323
  "testing-standards",
324
324
  "framework-decisions",
325
- "session-continuity"
325
+ "session-continuity",
326
+ "line-ending-baseline"
326
327
  ]
327
328
  },
328
329
  {
@@ -346,7 +347,8 @@ var PROFILE_CONFIGS = [
346
347
  "decision-documentation-standard",
347
348
  "agent-work-queue-model",
348
349
  "development-methodology",
349
- "spell-authoring-standards"
350
+ "spell-authoring-standards",
351
+ "line-ending-baseline"
350
352
  ]
351
353
  },
352
354
  {
@@ -371,6 +373,7 @@ var PROFILE_CONFIGS = [
371
373
  "spells-meta",
372
374
  "spells-docs",
373
375
  "docs-baseline",
376
+ "line-ending-baseline",
374
377
  "records-conventions",
375
378
  "agent-output-instructions",
376
379
  "git-conventions",
@@ -417,7 +420,8 @@ var PROFILE_CONFIGS = [
417
420
  "web-discoverability-standards",
418
421
  "mobile-release-standards",
419
422
  "records-conventions",
420
- "compliance-standards"
423
+ "compliance-standards",
424
+ "line-ending-baseline"
421
425
  ]
422
426
  }
423
427
  ];
@@ -546,15 +550,26 @@ var COMPONENTS = [
546
550
  // non-dotfile path and mapped via sourceOverrides; see RegistryComponent.
547
551
  {
548
552
  name: "docs-baseline",
549
- description: "Line-ending and binary-format baseline for document repositories (.gitattributes/.gitignore)",
550
- files: [".gitattributes", ".gitignore"],
553
+ description: "Ignore baseline for document repositories (.gitignore)",
554
+ files: [".gitignore"],
551
555
  sourceOverrides: {
552
- ".gitattributes": "docs-baseline/gitattributes",
553
556
  ".gitignore": "docs-baseline/gitignore"
554
557
  },
555
558
  skipExisting: true,
556
559
  initOnly: true
557
560
  },
561
+ // Split out of docs-baseline so every profile ships it (#281, PRD D-09);
562
+ // same asset source. See COMPONENT_FILE_MOVES for manifests written before.
563
+ {
564
+ name: "line-ending-baseline",
565
+ description: "Line-ending and binary-format baseline (.gitattributes): text normalizes to LF, binary formats are never rewritten",
566
+ files: [".gitattributes"],
567
+ sourceOverrides: {
568
+ ".gitattributes": "docs-baseline/gitattributes"
569
+ },
570
+ skipExisting: true,
571
+ initOnly: true
572
+ },
558
573
  // Optional MCP config scaffold (BC-22/I12). skipExisting: a repo's own
559
574
  // .mcp.json is user-owned server config Arcane must never overwrite.
560
575
  // Not initOnly -- useful to retrofit via `spell add` on an existing repo too.
@@ -782,6 +797,14 @@ var COMPONENTS = [
782
797
  ".github/prompts/spell-compliance.prompt.md",
783
798
  ".claude/commands/spell-compliance.md",
784
799
  ".agents/skills/spell-compliance/SKILL.md"
800
+ ],
801
+ // spell-make-discoverable cites the first two for every EV-/WD- rationale;
802
+ // spell-eas-store-deploy and spell-compliance consume the last two.
803
+ requires: [
804
+ "external-verification-standards",
805
+ "web-discoverability-standards",
806
+ "mobile-release-standards",
807
+ "compliance-standards"
785
808
  ]
786
809
  },
787
810
  {
@@ -886,6 +909,41 @@ var LEGACY_COMPONENT_MIGRATIONS = Object.assign(/* @__PURE__ */ Object.create(nu
886
909
  "spell-prompts": LEGACY_MONOLITH_REPLACEMENTS,
887
910
  "claude-commands": LEGACY_MONOLITH_REPLACEMENTS
888
911
  });
912
+ var REGISTRY_RETIREMENTS = [
913
+ {
914
+ name: "spell-bootstrap-business",
915
+ kind: "spell",
916
+ successors: ["spell-summon-venture"],
917
+ reason: "renamed in 0.16.0 with no alias (ARC-008 precedent) \u2014 it became a hub-gated venture creator with per-venture books and a registry entry"
918
+ },
919
+ {
920
+ name: "spell-assess",
921
+ kind: "spell",
922
+ successors: ["spell-scope"],
923
+ reason: "renamed to name its outcome, epic scoping and sequencing (ARC-007); no alias (ARC-008)"
924
+ },
925
+ {
926
+ name: "agent-files",
927
+ kind: "component",
928
+ successors: [],
929
+ reason: "`.github/agents/*.agent.md` files are rendered per roster by `spell agents init` / `spell agents sync` (ARC-047), which a fixed-path component cannot do"
930
+ },
931
+ {
932
+ name: "spell-prompts",
933
+ kind: "component",
934
+ successors: LEGACY_MONOLITH_REPLACEMENTS,
935
+ reason: "split in 0.18.0 into capability-scoped spells-* components; `spell update` migrates the entry itself"
936
+ },
937
+ {
938
+ name: "claude-commands",
939
+ kind: "component",
940
+ successors: LEGACY_MONOLITH_REPLACEMENTS,
941
+ reason: "split in 0.18.0 into capability-scoped spells-* components; `spell update` migrates the entry itself"
942
+ }
943
+ ];
944
+ var COMPONENT_FILE_MOVES = [
945
+ { from: "docs-baseline", to: "line-ending-baseline", files: [".gitattributes"] }
946
+ ];
889
947
  function getComponent(name) {
890
948
  const component = componentIndex.get(name);
891
949
  if (!component) {
@@ -3874,8 +3932,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3874
3932
  \u2726 Arcane v${packageVersion}
3875
3933
  `));
3876
3934
  let repoNotFound = false;
3935
+ let repoHasCommits = false;
3877
3936
  if (!options.dryRun) {
3878
3937
  const gitState = await inspectGitRepository(targetDir);
3938
+ repoHasCommits = gitState.status !== "not-repository" && gitState.status !== "no-commits";
3879
3939
  if (gitState.status === "not-repository") {
3880
3940
  repoNotFound = true;
3881
3941
  } else {
@@ -4228,9 +4288,15 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
4228
4288
  'Initialize Git for this project: `git init -b main` (avoids defaulting to "master" on systems where that\'s the system default)'
4229
4289
  );
4230
4290
  }
4291
+ const wroteGitattributes = installedComponents.some((c) => c.files.includes(".gitattributes"));
4292
+ if (wroteGitattributes && repoHasCommits) {
4293
+ printWarning(
4294
+ "Installed .gitattributes, which normalizes text to LF. In a repository with history, files committed with CRLF can show as modified once Git renormalizes them (for example after `git add --renormalize .`). Review and commit that as its own change."
4295
+ );
4296
+ }
4231
4297
  printNextStep(
4232
4298
  step++,
4233
- "Commit the new files: git add .arcane .github .claude AGENTS.md CLAUDE.md .arcane.json"
4299
+ `Commit the new files: git add .arcane .github .claude AGENTS.md CLAUDE.md .arcane.json${wroteGitattributes ? " .gitattributes" : ""}`
4234
4300
  );
4235
4301
  console.log();
4236
4302
  }
@@ -4422,7 +4488,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
4422
4488
  }
4423
4489
 
4424
4490
  // src/commands/update.ts
4425
- import { dirname as dirname5, join as join9 } from "path";
4491
+ import { dirname as dirname5, join as join10 } from "path";
4426
4492
  import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
4427
4493
 
4428
4494
  // src/modules/merge3.ts
@@ -4553,6 +4619,101 @@ function merge3(base, ours, theirs) {
4553
4619
  return { content: resultLines.join("\n"), hasConflict };
4554
4620
  }
4555
4621
 
4622
+ // src/modules/registry-changes.ts
4623
+ import { join as join9 } from "path";
4624
+ function findMissingRequires(installed, profile) {
4625
+ const names = new Set(installed.flatMap((c) => LEGACY_COMPONENT_MIGRATIONS[c.name] ?? [c.name]));
4626
+ const promised = listProfiles().find((p) => p.id === profile)?.components;
4627
+ const missing = /* @__PURE__ */ new Map();
4628
+ for (const name of names) {
4629
+ let requires;
4630
+ try {
4631
+ requires = getComponent(name).requires ?? [];
4632
+ } catch (err) {
4633
+ if (err instanceof ComponentNotFoundError) continue;
4634
+ throw err;
4635
+ }
4636
+ for (const required of requires) {
4637
+ if (names.has(required)) continue;
4638
+ if (promised && !promised.includes(required)) continue;
4639
+ missing.set(required, [...missing.get(required) ?? [], name]);
4640
+ }
4641
+ }
4642
+ return [...missing].map(([name, requiredBy]) => ({ name, requiredBy }));
4643
+ }
4644
+ var SPELL_ID_IN_PATH = /(?:^|\/)(spell-[a-z0-9-]+)(?:\/SKILL\.md|\.prompt\.md|\.md)$/;
4645
+ function spellIdFromTrackedPath(file) {
4646
+ return SPELL_ID_IN_PATH.exec(file)?.[1];
4647
+ }
4648
+ function findTrackedRetirements(components) {
4649
+ const componentNames = new Set(components.map((c) => c.name));
4650
+ const spellIds = new Set(
4651
+ components.flatMap((c) => c.files).map(spellIdFromTrackedPath).filter((id) => id !== void 0)
4652
+ );
4653
+ return REGISTRY_RETIREMENTS.filter(
4654
+ (entry) => entry.kind === "component" ? componentNames.has(entry.name) : spellIds.has(entry.name)
4655
+ );
4656
+ }
4657
+ async function findNewlyAvailable(targetDir, profile, installed, spellScope) {
4658
+ const definition = listProfiles().find((p) => p.id === profile);
4659
+ if (!definition) return [];
4660
+ const installedNames = new Set(installed.map((c) => c.name));
4661
+ const found = [];
4662
+ for (const name of definition.components) {
4663
+ if (installedNames.has(name)) continue;
4664
+ if (spellScope === "user" && SPELL_COMPONENT_NAMES.includes(name)) continue;
4665
+ const component = getComponent(name);
4666
+ const alreadyPresent = [];
4667
+ if (component.skipExisting) {
4668
+ for (const file of component.files) {
4669
+ if (await fileExists(join9(targetDir, file))) alreadyPresent.push(file);
4670
+ }
4671
+ if (component.files.length > 0 && alreadyPresent.length === component.files.length) continue;
4672
+ }
4673
+ found.push({ name, description: component.description, alreadyPresent });
4674
+ }
4675
+ return found;
4676
+ }
4677
+ function formatRegistryChanges(changes) {
4678
+ const missingRequires = changes.missingRequires ?? [];
4679
+ const newlyAvailable = (changes.newlyAvailable ?? []).filter(
4680
+ (c) => !missingRequires.some((m) => m.name === c.name)
4681
+ );
4682
+ if (changes.retired.length === 0 && newlyAvailable.length === 0 && missingRequires.length === 0) return [];
4683
+ const lines = ["", "Registry changes since your install:"];
4684
+ if (changes.retired.length > 0) {
4685
+ lines.push(" Renamed or retired, still tracked here:");
4686
+ for (const entry of changes.retired) {
4687
+ const successor = entry.successors.length > 0 ? entry.successors.join(", ") : "no successor";
4688
+ lines.push(` ${entry.name} (${entry.kind}) \u2192 ${successor}: ${entry.reason}`);
4689
+ }
4690
+ lines.push(
4691
+ " Nothing is deleted because of this list: their files go through the orphan handling above, which removes an untouched one only under `spell update --prune`."
4692
+ );
4693
+ }
4694
+ if (newlyAvailable.length > 0) {
4695
+ const profile = changes.profile ? ` "${changes.profile}"` : "";
4696
+ lines.push(` Newly available for your${profile} profile, not installed \u2014 nothing was added:`);
4697
+ for (const component of newlyAvailable) {
4698
+ lines.push(` ${component.name} \u2014 ${component.description}`);
4699
+ lines.push(` spell add ${component.name}`);
4700
+ if (component.alreadyPresent.length > 0) {
4701
+ lines.push(
4702
+ ` (already here and yours: ${component.alreadyPresent.join(", ")} \u2014 \`spell add\` stops at an existing file rather than overwrite it)`
4703
+ );
4704
+ }
4705
+ }
4706
+ }
4707
+ if (missingRequires.length > 0) {
4708
+ lines.push(" Missing prerequisites \u2014 installed components cite these, and they are not installed:");
4709
+ for (const { name, requiredBy } of missingRequires) {
4710
+ lines.push(` ${name} (required by ${requiredBy.join(", ")})`);
4711
+ lines.push(` spell add ${name}`);
4712
+ }
4713
+ }
4714
+ return lines;
4715
+ }
4716
+
4556
4717
  // src/modules/npm-registry.ts
4557
4718
  var NPM_PACKAGE_NAME = "arcane-cli";
4558
4719
  async function fetchPublishedFile(version, relativeAssetPath) {
@@ -4634,6 +4795,44 @@ function migrateLegacyComponents(components) {
4634
4795
  inherited: { files: inheritedFiles, fileHashes: inheritedHashes, carrier }
4635
4796
  };
4636
4797
  }
4798
+ function moveComponentFiles(components, present = () => true) {
4799
+ let out = components;
4800
+ for (const move of COMPONENT_FILE_MOVES) {
4801
+ const from = out.find((c) => c.name === move.from);
4802
+ const tracked = from?.files.filter((f) => move.files.includes(f)) ?? [];
4803
+ if (!from || tracked.length === 0) continue;
4804
+ const moving = tracked.filter(present);
4805
+ const dropped = tracked.filter((f) => !present(f));
4806
+ const movedHashes = {};
4807
+ const keptHashes = {};
4808
+ for (const [file, hash] of Object.entries(from.fileHashes ?? {})) {
4809
+ if (dropped.includes(file)) continue;
4810
+ (moving.includes(file) ? movedHashes : keptHashes)[file] = hash;
4811
+ }
4812
+ const newFrom = {
4813
+ ...from,
4814
+ files: from.files.filter((f) => !tracked.includes(f)),
4815
+ ...from.fileHashes ? { fileHashes: keptHashes } : {}
4816
+ };
4817
+ const existing = out.find((c) => c.name === move.to);
4818
+ const newTo = existing ? {
4819
+ ...existing,
4820
+ files: [...existing.files, ...moving.filter((f) => !existing.files.includes(f))],
4821
+ fileHashes: { ...movedHashes, ...existing.fileHashes }
4822
+ } : {
4823
+ name: move.to,
4824
+ files: moving,
4825
+ installedVersion: from.installedVersion,
4826
+ ...from.fileHashes ? { fileHashes: movedHashes } : {}
4827
+ };
4828
+ out = out.flatMap((c) => {
4829
+ if (c === from) return existing || moving.length === 0 ? [newFrom] : [newFrom, newTo];
4830
+ if (c === existing) return [newTo];
4831
+ return [c];
4832
+ });
4833
+ }
4834
+ return out;
4835
+ }
4637
4836
  async function resolveOrphan(targetDir, file, recordedHash, prune) {
4638
4837
  try {
4639
4838
  validateTargetPath(targetDir, file);
@@ -4641,7 +4840,7 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
4641
4840
  console.log(` ! Refused (path escapes the target directory): ${file}`);
4642
4841
  return "reported";
4643
4842
  }
4644
- const filePath = join9(targetDir, file);
4843
+ const filePath = join10(targetDir, file);
4645
4844
  if (!await fileExists(filePath)) return "not-found";
4646
4845
  if (!prune) return "reported";
4647
4846
  if (recordedHash === void 0) {
@@ -4663,7 +4862,7 @@ async function retainReportedOrphan(targetDir, file, recordedHash, into) {
4663
4862
  } catch {
4664
4863
  return;
4665
4864
  }
4666
- if (!await fileExists(join9(targetDir, file))) return;
4865
+ if (!await fileExists(join10(targetDir, file))) return;
4667
4866
  if (!into.files.includes(file)) into.files.push(file);
4668
4867
  if (recordedHash !== void 0) {
4669
4868
  into.fileHashes ??= {};
@@ -4692,7 +4891,7 @@ async function findMissingTrackedFiles(targetDir, components, scope = "repo", sp
4692
4891
  const candidates = readopted.has(installed.name) ? component.files : installed.files;
4693
4892
  for (const file of candidates) {
4694
4893
  if (!component.files.includes(file)) continue;
4695
- if (!await fileExists(join9(targetDir, file))) missing.push(file);
4894
+ if (!await fileExists(join10(targetDir, file))) missing.push(file);
4696
4895
  }
4697
4896
  }
4698
4897
  return missing;
@@ -4731,6 +4930,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4731
4930
  }
4732
4931
  throw err;
4733
4932
  }
4933
+ const movedFilesPresent = /* @__PURE__ */ new Set();
4934
+ for (const move of COMPONENT_FILE_MOVES) {
4935
+ for (const file of move.files) {
4936
+ if (await fileExists(join10(targetDir, file))) movedFilesPresent.add(file);
4937
+ }
4938
+ }
4939
+ manifest = {
4940
+ ...manifest,
4941
+ components: moveComponentFiles(manifest.components, (f) => movedFilesPresent.has(f))
4942
+ };
4734
4943
  const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4735
4944
  if (resolvedScope.misplacedUserManifest) {
4736
4945
  console.error(misplacedUserManifestMessage(targetDir));
@@ -4748,7 +4957,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4748
4957
  return;
4749
4958
  }
4750
4959
  try {
4751
- flagPatch = resolveManifestFlags(options.manifestFlags, manifest, join9(targetDir, ".arcane.json"));
4960
+ flagPatch = resolveManifestFlags(options.manifestFlags, manifest, join10(targetDir, ".arcane.json"));
4752
4961
  } catch (err) {
4753
4962
  if (!(err instanceof ManifestFlagError)) throw err;
4754
4963
  console.error(`
@@ -4793,12 +5002,29 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4793
5002
  return;
4794
5003
  }
4795
5004
  }
5005
+ const registryChanges = {
5006
+ retired: findTrackedRetirements(manifest.components),
5007
+ ...scope === "repo" ? {
5008
+ profile: manifest.profile,
5009
+ newlyAvailable: await findNewlyAvailable(
5010
+ targetDir,
5011
+ manifest.profile,
5012
+ migrateLegacyComponents(manifest.components).components,
5013
+ spellScope
5014
+ ),
5015
+ missingRequires: findMissingRequires(manifest.components, manifest.profile)
5016
+ } : {}
5017
+ };
5018
+ const printRegistryChanges = () => {
5019
+ for (const line of formatRegistryChanges(registryChanges)) console.log(line);
5020
+ };
4796
5021
  let sameVersionRestore = false;
4797
5022
  if (manifest.version === packageVersion && manifest.components.length > 0) {
4798
5023
  const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
4799
5024
  const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
4800
5025
  if (missing.length === 0 && unmanaged.length === 0) {
4801
5026
  console.log("Already up to date.");
5027
+ printRegistryChanges();
4802
5028
  if (scope === "repo" && Object.keys(flagPatch).length > 0) {
4803
5029
  if (!options.dryRun) await writeManifest(targetDir, answered);
4804
5030
  reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
@@ -4887,8 +5113,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4887
5113
  const updatedFiles = [];
4888
5114
  const fileHashes = {};
4889
5115
  for (const file of component.files) {
4890
- const srcPath = join9(assetsDir, component.sourceOverrides?.[file] ?? file);
4891
- const targetExists = await fileExists(join9(targetDir, file));
5116
+ const srcPath = join10(assetsDir, component.sourceOverrides?.[file] ?? file);
5117
+ const targetExists = await fileExists(join10(targetDir, file));
4892
5118
  const preserveExisting = Boolean(component.skipExisting) && targetExists;
4893
5119
  const recordedHash = installed.fileHashes?.[file];
4894
5120
  if (component.initOnly && !targetExists) {
@@ -4908,7 +5134,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4908
5134
  continue;
4909
5135
  }
4910
5136
  }
4911
- const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join9(targetDir, file), recordedHash);
5137
+ const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join10(targetDir, file), recordedHash);
4912
5138
  if (preserveExisting) {
4913
5139
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
4914
5140
  } else if (editedByOperator && isClientShimPath(file)) {
@@ -4935,11 +5161,11 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4935
5161
  handled = true;
4936
5162
  } else {
4937
5163
  const [currentContent, newContent] = await Promise.all([
4938
- readFile7(join9(targetDir, file), "utf-8"),
5164
+ readFile7(join10(targetDir, file), "utf-8"),
4939
5165
  readFile7(srcPath, "utf-8")
4940
5166
  ]);
4941
5167
  const result = merge3(oldVendorContent, currentContent, newContent);
4942
- await writeFile9(join9(targetDir, file), result.content, "utf-8");
5168
+ await writeFile9(join10(targetDir, file), result.content, "utf-8");
4943
5169
  fileHashes[file] = await hashFile(srcPath);
4944
5170
  if (result.hasConflict) {
4945
5171
  conflictedFiles.push(file);
@@ -4961,7 +5187,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4961
5187
  }
4962
5188
  }
4963
5189
  for (const dir of component.directories ?? []) {
4964
- const srcDirPath = join9(assetsDir, dir);
5190
+ const srcDirPath = join10(assetsDir, dir);
4965
5191
  if (options.dryRun) {
4966
5192
  console.log(` [dry-run] Would update directory: ${dir}/`);
4967
5193
  } else {
@@ -5028,6 +5254,7 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
5028
5254
  console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
5029
5255
  }
5030
5256
  }
5257
+ printRegistryChanges();
5031
5258
  if (customizedShims.length > 0) {
5032
5259
  const verb = options.dryRun ? "[dry-run] Would keep" : "Kept";
5033
5260
  console.log(
@@ -5123,7 +5350,7 @@ import { dirname as dirname7 } from "path";
5123
5350
 
5124
5351
  // src/modules/version-check.ts
5125
5352
  import { readFile as readFile8, writeFile as writeFile10, mkdir as mkdir6 } from "fs/promises";
5126
- import { dirname as dirname6, join as join10 } from "path";
5353
+ import { dirname as dirname6, join as join11 } from "path";
5127
5354
  import { homedir as homedir3 } from "os";
5128
5355
  import { createRequire } from "module";
5129
5356
  var require2 = createRequire(import.meta.url);
@@ -5137,7 +5364,7 @@ function getFeedUrl() {
5137
5364
  }
5138
5365
  return url;
5139
5366
  }
5140
- var DEFAULT_CACHE_PATH = join10(homedir3(), ".arcane", "version-cache.json");
5367
+ var DEFAULT_CACHE_PATH = join11(homedir3(), ".arcane", "version-cache.json");
5141
5368
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
5142
5369
  async function readCache(cachePath) {
5143
5370
  try {
@@ -5356,7 +5583,7 @@ async function runStatus(targetDir, packageVersion, options = {}) {
5356
5583
 
5357
5584
  // src/commands/uninstall.ts
5358
5585
  import { rm as rm5 } from "fs/promises";
5359
- import { dirname as dirname8, join as join11 } from "path";
5586
+ import { dirname as dirname8, join as join12 } from "path";
5360
5587
  import { confirm as confirm3 } from "@inquirer/prompts";
5361
5588
  async function runUninstall(options, targetDir) {
5362
5589
  let manifest;
@@ -5439,16 +5666,16 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
5439
5666
  }
5440
5667
  }
5441
5668
  }
5442
- await rm5(join11(targetDir, ".arcane.json"), { force: true });
5669
+ await rm5(join12(targetDir, ".arcane.json"), { force: true });
5443
5670
  const agentDirs = [
5444
5671
  ".github/agents",
5445
5672
  ".arcane/agents",
5446
5673
  ".arcane/generated"
5447
5674
  ];
5448
5675
  for (const dir of agentDirs) {
5449
- await rm5(join11(targetDir, dir), { recursive: true, force: true });
5676
+ await rm5(join12(targetDir, dir), { recursive: true, force: true });
5450
5677
  }
5451
- await rm5(join11(targetDir, ".arcane/agents.yaml"), { force: true });
5678
+ await rm5(join12(targetDir, ".arcane/agents.yaml"), { force: true });
5452
5679
  const markerFiles = [
5453
5680
  "CLAUDE.md",
5454
5681
  "AGENTS.md",
@@ -5510,10 +5737,10 @@ async function uninstallUserTier(options, storeRoot, manifest) {
5510
5737
  console.log(` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
5511
5738
  }
5512
5739
  }
5513
- await rm5(join11(storeRoot, ".arcane.json"), { force: true });
5514
- await rm5(join11(storeRoot, "agents"), { recursive: true, force: true });
5515
- await rm5(join11(storeRoot, "agents.yaml"), { force: true });
5516
- await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
5740
+ await rm5(join12(storeRoot, ".arcane.json"), { force: true });
5741
+ await rm5(join12(storeRoot, "agents"), { recursive: true, force: true });
5742
+ await rm5(join12(storeRoot, "agents.yaml"), { force: true });
5743
+ await removeDirectoryIfEmpty(join12(storeRoot, USER_TIER_SPELLS_DIR));
5517
5744
  await removeDirectoryIfEmpty(storeRoot);
5518
5745
  for (const line of describeFanoutOutcomes(fanout.outcomes)) console.log(` ${line}`);
5519
5746
  const pruned = fanout.outcomes.filter((o) => o.status === "pruned").length;
@@ -5526,7 +5753,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
5526
5753
 
5527
5754
  // src/commands/doctor.ts
5528
5755
  import { access as access4, readdir as readdir9, readFile as readFile11, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
5529
- import { join as join14, dirname as dirname9 } from "path";
5756
+ import { join as join15, dirname as dirname9 } from "path";
5530
5757
 
5531
5758
  // src/config/incidents.ts
5532
5759
  var INCIDENT_QUEUE = [
@@ -5595,7 +5822,7 @@ function evaluateIncidentGate(records) {
5595
5822
 
5596
5823
  // src/modules/denylist-scan.ts
5597
5824
  import { readFile as readFile9, readdir as readdir7 } from "fs/promises";
5598
- import { join as join12, relative } from "path";
5825
+ import { join as join13, relative } from "path";
5599
5826
  function escapeRegExp(value) {
5600
5827
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5601
5828
  }
@@ -5646,7 +5873,7 @@ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT
5646
5873
  return collected;
5647
5874
  }
5648
5875
  for (const entry of entries) {
5649
- const absolutePath = join12(root, entry.name);
5876
+ const absolutePath = join13(root, entry.name);
5650
5877
  if (entry.isDirectory()) {
5651
5878
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
5652
5879
  collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
@@ -5863,7 +6090,7 @@ function parseAdoRemote(url) {
5863
6090
 
5864
6091
  // src/modules/placeholders.ts
5865
6092
  import { readdir as readdir8, readFile as readFile10 } from "fs/promises";
5866
- import { join as join13 } from "path";
6093
+ import { join as join14 } from "path";
5867
6094
  var PLACEHOLDER_STANDARD_FILE = "spell-authoring-standards.md";
5868
6095
  var RUNTIME_PLACEHOLDERS_START = "<!-- runtime-placeholders:start -->";
5869
6096
  var RUNTIME_PLACEHOLDERS_END = "<!-- runtime-placeholders:end -->";
@@ -5958,7 +6185,7 @@ function parseRuntimePlaceholders(standardContent) {
5958
6185
  async function scanGovernancePlaceholders(governanceDir) {
5959
6186
  let standard;
5960
6187
  try {
5961
- standard = await readFile10(join13(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
6188
+ standard = await readFile10(join14(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
5962
6189
  } catch (err) {
5963
6190
  if (err.code === "ENOENT") {
5964
6191
  return { status: "skipped", reason: `${PLACEHOLDER_STANDARD_FILE} is not installed` };
@@ -5977,7 +6204,7 @@ async function scanGovernancePlaceholders(governanceDir) {
5977
6204
  const templateDocs = [];
5978
6205
  const findings = [];
5979
6206
  for (const file of files) {
5980
- const content = await readFile10(join13(governanceDir, file), "utf8");
6207
+ const content = await readFile10(join14(governanceDir, file), "utf8");
5981
6208
  const status = parseFrontmatterStatus(content);
5982
6209
  if (status === "template") templateDocs.push(file);
5983
6210
  if (status !== "active") continue;
@@ -6008,8 +6235,8 @@ async function checkNodeVersion() {
6008
6235
  async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
6009
6236
  const name = `VS Code extension: ${label}`;
6010
6237
  const flavours = [
6011
- { dir: join14(homeDir, ".vscode", "extensions"), bin: "code" },
6012
- { dir: join14(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
6238
+ { dir: join15(homeDir, ".vscode", "extensions"), bin: "code" },
6239
+ { dir: join15(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
6013
6240
  ];
6014
6241
  let installBin = null;
6015
6242
  for (const flavour of flavours) {
@@ -6037,11 +6264,11 @@ async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
6037
6264
  }
6038
6265
  async function checkArcaneManifest(targetDir) {
6039
6266
  const name = "Arcane manifest (.arcane.json)";
6040
- const manifestPath2 = join14(targetDir, ".arcane.json");
6267
+ const manifestPath2 = join15(targetDir, ".arcane.json");
6041
6268
  try {
6042
6269
  await access4(manifestPath2);
6043
6270
  } catch {
6044
- const selfHostedManifestPath = join14(targetDir, "src", "assets", ".arcane.json");
6271
+ const selfHostedManifestPath = join15(targetDir, "src", "assets", ".arcane.json");
6045
6272
  try {
6046
6273
  const raw = await readFile11(selfHostedManifestPath, "utf8");
6047
6274
  const manifest = JSON.parse(raw);
@@ -6147,7 +6374,7 @@ async function checkSessionContinuity(targetDir) {
6147
6374
  const results = [];
6148
6375
  for (const file of SESSION_CONTINUITY_FILES) {
6149
6376
  const name = `Session file: ${file}`;
6150
- const filePath = join14(targetDir, file);
6377
+ const filePath = join15(targetDir, file);
6151
6378
  try {
6152
6379
  await access4(filePath);
6153
6380
  results.push({ name, passed: true, message: "present" });
@@ -6165,11 +6392,11 @@ async function checkSessionContinuity(targetDir) {
6165
6392
  async function fixSessionContinuity(targetDir, assetsDir) {
6166
6393
  const created = [];
6167
6394
  for (const file of SESSION_CONTINUITY_FILES) {
6168
- const destPath = join14(targetDir, file);
6395
+ const destPath = join15(targetDir, file);
6169
6396
  try {
6170
6397
  await access4(destPath);
6171
6398
  } catch {
6172
- const srcPath = join14(assetsDir, file);
6399
+ const srcPath = join15(assetsDir, file);
6173
6400
  await mkdir7(dirname9(destPath), { recursive: true });
6174
6401
  await fsCopyFile(srcPath, destPath);
6175
6402
  created.push(file);
@@ -6297,7 +6524,7 @@ async function checkGovernancePlaceholders(targetDir) {
6297
6524
  const name = "Governance placeholders (ARC-051)";
6298
6525
  let scan;
6299
6526
  try {
6300
- scan = await scanGovernancePlaceholders(join14(targetDir, ".arcane", "governance"));
6527
+ scan = await scanGovernancePlaceholders(join15(targetDir, ".arcane", "governance"));
6301
6528
  } catch (err) {
6302
6529
  return {
6303
6530
  name,
@@ -6325,9 +6552,34 @@ async function checkGovernancePlaceholders(targetDir) {
6325
6552
  message: `unknown placeholder(s) in status: active doc(s) \u2014 ${listed.join("; ")}. Fill each one in; or, if an agent resolves it at use time, add it to the runtime-placeholders list in .arcane/governance/${PLACEHOLDER_STANDARD_FILE}; or, if the document is meant to be filled in by hand, mark it \`status: template\`.`
6326
6553
  };
6327
6554
  }
6555
+ async function checkComponentRequires(targetDir) {
6556
+ const name = "Component prerequisites";
6557
+ let manifest;
6558
+ try {
6559
+ manifest = await readManifest(targetDir);
6560
+ } catch {
6561
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 no readable .arcane.json" };
6562
+ }
6563
+ if (manifest.scope === "user") {
6564
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 the user tier holds spells only" };
6565
+ }
6566
+ if (!Array.isArray(manifest.components)) {
6567
+ return { name, passed: true, blocking: false, skipped: true, message: "skipped \u2014 .arcane.json lists no components" };
6568
+ }
6569
+ const missing = findMissingRequires(manifest.components, manifest.profile);
6570
+ if (missing.length === 0) {
6571
+ return { name, passed: true, blocking: false, message: "every installed component's prerequisites are installed" };
6572
+ }
6573
+ return {
6574
+ name,
6575
+ passed: false,
6576
+ blocking: false,
6577
+ message: missing.map(({ name: required, requiredBy }) => `${requiredBy.join(", ")} cites ${required}, which is not installed \u2014 \`spell add ${required}\``).join("; ")
6578
+ };
6579
+ }
6328
6580
  async function checkMcpConfig(targetDir) {
6329
6581
  const name = "MCP config (per-server timeout, I12)";
6330
- const filePath = join14(targetDir, ".mcp.json");
6582
+ const filePath = join15(targetDir, ".mcp.json");
6331
6583
  let raw;
6332
6584
  try {
6333
6585
  raw = await readFile11(filePath, "utf8");
@@ -6383,7 +6635,7 @@ function isDelegationsFile(value) {
6383
6635
  }
6384
6636
  async function checkDelegations(targetDir) {
6385
6637
  const name = "Standing delegations (T13)";
6386
- const filePath = join14(targetDir, ".arcane", "delegations.json");
6638
+ const filePath = join15(targetDir, ".arcane", "delegations.json");
6387
6639
  let raw;
6388
6640
  try {
6389
6641
  raw = await readFile11(filePath, "utf8");
@@ -6496,7 +6748,7 @@ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
6496
6748
  let probed = false;
6497
6749
  const probe = "spell-status.md";
6498
6750
  try {
6499
- const entries = await readdir9(join14(storeRoot, USER_TIER_SPELLS_DIR));
6751
+ const entries = await readdir9(join15(storeRoot, USER_TIER_SPELLS_DIR));
6500
6752
  const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
6501
6753
  spellCount = spells.length;
6502
6754
  probed = spells.includes(probe);
@@ -6560,7 +6812,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
6560
6812
  checkMcpConfig(targetDir),
6561
6813
  checkUserTier(resolveHomeDir(), options.packageVersion),
6562
6814
  checkSpellScope(targetDir),
6563
- checkGovernancePlaceholders(targetDir)
6815
+ checkGovernancePlaceholders(targetDir),
6816
+ checkComponentRequires(targetDir)
6564
6817
  ]);
6565
6818
  const sessionResults = await checkSessionContinuity(targetDir);
6566
6819
  results.push(...sessionResults);
@@ -6608,7 +6861,7 @@ import { readFile as readFile13 } from "fs/promises";
6608
6861
 
6609
6862
  // src/modules/ward.ts
6610
6863
  import { readdir as readdir10, readFile as readFile12 } from "fs/promises";
6611
- import { join as join15, relative as relative2 } from "path";
6864
+ import { join as join16, relative as relative2 } from "path";
6612
6865
  async function scanFileAllRules(absolutePath, displayPath, rules) {
6613
6866
  const findings = [];
6614
6867
  let content;
@@ -6671,7 +6924,7 @@ async function scanFilenames(root, rules, repoRoot = root) {
6671
6924
  return findings;
6672
6925
  }
6673
6926
  for (const entry of entries) {
6674
- const absolutePath = join15(root, entry.name);
6927
+ const absolutePath = join16(root, entry.name);
6675
6928
  if (entry.isDirectory()) {
6676
6929
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
6677
6930
  findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
@@ -6948,15 +7201,15 @@ async function runBlockPush(targetDir) {
6948
7201
 
6949
7202
  // src/commands/report.ts
6950
7203
  import { access as access5 } from "fs/promises";
6951
- import { join as join18 } from "path";
7204
+ import { join as join19 } from "path";
6952
7205
 
6953
7206
  // src/modules/show-report/generate.ts
6954
7207
  import { mkdir as mkdir8, readdir as readdir11, readFile as readFile15, writeFile as writeFile11 } from "fs/promises";
6955
- import { basename as basename3, dirname as dirname10, isAbsolute, join as join17, relative as relative3, resolve as resolve4 } from "path";
7208
+ import { basename as basename3, dirname as dirname10, isAbsolute, join as join18, relative as relative3, resolve as resolve4 } from "path";
6956
7209
 
6957
7210
  // src/modules/show-report/model.ts
6958
7211
  import { readFile as readFile14 } from "fs/promises";
6959
- import { join as join16 } from "path";
7212
+ import { join as join17 } from "path";
6960
7213
 
6961
7214
  // src/modules/show-report/plan-parser.ts
6962
7215
  function parseFrontmatter(content) {
@@ -7322,11 +7575,11 @@ function buildCorrections(ledgerRows, programName) {
7322
7575
  };
7323
7576
  }
7324
7577
  async function buildShowReportModel(options) {
7325
- const planAbsPath = join16(options.rootDir, options.planRelPath);
7326
- const queueAbsPath = join16(options.rootDir, options.queueRelPath);
7578
+ const planAbsPath = join17(options.rootDir, options.planRelPath);
7579
+ const queueAbsPath = join17(options.rootDir, options.queueRelPath);
7327
7580
  const ledgerRelPath = "docs/verification-ledger.md";
7328
- const ledgerAbsPath = join16(options.rootDir, ledgerRelPath);
7329
- const decisionsAbsPath = join16(options.rootDir, "DECISIONS.md");
7581
+ const ledgerAbsPath = join17(options.rootDir, ledgerRelPath);
7582
+ const decisionsAbsPath = join17(options.rootDir, "DECISIONS.md");
7330
7583
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
7331
7584
  readFile14(planAbsPath, "utf8"),
7332
7585
  readOptional(queueAbsPath),
@@ -7497,7 +7750,7 @@ function toPosix(path4) {
7497
7750
  async function programFromPlan(rootDir, slug, planRelPath) {
7498
7751
  let planContent;
7499
7752
  try {
7500
- planContent = await readFile15(join17(rootDir, planRelPath), "utf8");
7753
+ planContent = await readFile15(join18(rootDir, planRelPath), "utf8");
7501
7754
  } catch {
7502
7755
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
7503
7756
  }
@@ -7509,13 +7762,13 @@ async function programFromPlan(rootDir, slug, planRelPath) {
7509
7762
  program: {
7510
7763
  slug,
7511
7764
  planRelPath,
7512
- queueRelPath: toPosix(join17(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
7765
+ queueRelPath: toPosix(join18(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
7513
7766
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
7514
7767
  }
7515
7768
  };
7516
7769
  }
7517
7770
  async function discoverPrograms(rootDir) {
7518
- const plansDir = join17(rootDir, "docs", "plans");
7771
+ const plansDir = join18(rootDir, "docs", "plans");
7519
7772
  let entries;
7520
7773
  try {
7521
7774
  entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
@@ -7549,7 +7802,7 @@ async function readOptionalFile(path4) {
7549
7802
  }
7550
7803
  }
7551
7804
  async function generateReport(rootDir, program2, templateHtml) {
7552
- const planContent = await readFile15(join17(rootDir, program2.planRelPath), "utf8");
7805
+ const planContent = await readFile15(join18(rootDir, program2.planRelPath), "utf8");
7553
7806
  const frontmatter = parseFrontmatter(planContent);
7554
7807
  const model = await buildShowReportModel({
7555
7808
  rootDir,
@@ -7582,12 +7835,12 @@ async function runReportGeneration(options) {
7582
7835
  [`${outRel}/show-report.html`, generated.html]
7583
7836
  ];
7584
7837
  for (const [relPath, expected] of outputs) {
7585
- const actual = await readOptionalFile(join17(options.rootDir, relPath));
7838
+ const actual = await readOptionalFile(join18(options.rootDir, relPath));
7586
7839
  if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
7587
7840
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
7588
7841
  if (options.mode === "write") {
7589
7842
  await mkdir8(outAbs, { recursive: true });
7590
- await writeFile11(join17(options.rootDir, relPath), expected, "utf8");
7843
+ await writeFile11(join18(options.rootDir, relPath), expected, "utf8");
7591
7844
  repaired.push(relPath);
7592
7845
  }
7593
7846
  }
@@ -7598,7 +7851,7 @@ async function runReportGeneration(options) {
7598
7851
 
7599
7852
  // src/commands/report.ts
7600
7853
  async function runReport(targetDir, options = {}, assetsDir) {
7601
- const templatePath = join18(assetsDir, "report", "show-report.template.html");
7854
+ const templatePath = join19(assetsDir, "report", "show-report.template.html");
7602
7855
  try {
7603
7856
  await access5(templatePath);
7604
7857
  } catch {
@@ -7661,7 +7914,7 @@ async function runReport(targetDir, options = {}, assetsDir) {
7661
7914
  import { realpathSync } from "fs";
7662
7915
  var require3 = createRequire2(import.meta.url);
7663
7916
  var pkg = require3("../package.json");
7664
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join19(dirname11(fileURLToPath(import.meta.url)), "assets");
7917
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join20(dirname11(fileURLToPath(import.meta.url)), "assets");
7665
7918
  var program = new Command();
7666
7919
  var USER_FLAG_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 spells shared by every repository on this machine, fanned out to ~/.agents/skills (Codex, Copilot) and ~/.claude/commands (Claude Code)";
7667
7920
  var USER_TIER_AGENTS_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 one agent roster shared by every repository on this machine, rendered to ~/.copilot/agents (VS Code agent modes)";