arcane-cli 0.39.0 → 1.1.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.
Files changed (176) hide show
  1. package/README.md +14 -1
  2. package/dist/assets/.agents/skills/spell-address-review/SKILL.md +1 -1
  3. package/dist/assets/.agents/skills/spell-adopt-docs/SKILL.md +1 -1
  4. package/dist/assets/.agents/skills/spell-arcane-version/SKILL.md +1 -1
  5. package/dist/assets/.agents/skills/spell-architect/SKILL.md +1 -1
  6. package/dist/assets/.agents/skills/spell-brainstorm/SKILL.md +1 -1
  7. package/dist/assets/.agents/skills/spell-bug/SKILL.md +1 -1
  8. package/dist/assets/.agents/skills/spell-bump/SKILL.md +1 -1
  9. package/dist/assets/.agents/skills/spell-check-drift/SKILL.md +1 -1
  10. package/dist/assets/.agents/skills/spell-close-session/SKILL.md +1 -1
  11. package/dist/assets/.agents/skills/spell-commit-work/SKILL.md +1 -1
  12. package/dist/assets/.agents/skills/spell-compliance/SKILL.md +1 -1
  13. package/dist/assets/.agents/skills/spell-create-pull-request/SKILL.md +1 -1
  14. package/dist/assets/.agents/skills/spell-document/SKILL.md +1 -1
  15. package/dist/assets/.agents/skills/spell-dotnet-expert/SKILL.md +1 -1
  16. package/dist/assets/.agents/skills/spell-eas-store-deploy/SKILL.md +1 -1
  17. package/dist/assets/.agents/skills/spell-enchant/SKILL.md +1 -1
  18. package/dist/assets/.agents/skills/spell-explain-concept/SKILL.md +1 -1
  19. package/dist/assets/.agents/skills/spell-feedback/SKILL.md +1 -1
  20. package/dist/assets/.agents/skills/spell-full-cycle/SKILL.md +1 -1
  21. package/dist/assets/.agents/skills/spell-generate-bot-icons/SKILL.md +1 -1
  22. package/dist/assets/.agents/skills/spell-implement/SKILL.md +1 -1
  23. package/dist/assets/.agents/skills/spell-make-discoverable/SKILL.md +1 -1
  24. package/dist/assets/.agents/skills/spell-manifest/SKILL.md +1 -1
  25. package/dist/assets/.agents/skills/spell-open-session/SKILL.md +1 -1
  26. package/dist/assets/.agents/skills/spell-plan/SKILL.md +1 -1
  27. package/dist/assets/.agents/skills/spell-present-arcane/SKILL.md +1 -1
  28. package/dist/assets/.agents/skills/spell-product-review/SKILL.md +1 -1
  29. package/dist/assets/.agents/skills/spell-review/SKILL.md +1 -1
  30. package/dist/assets/.agents/skills/spell-review-batch/SKILL.md +1 -1
  31. package/dist/assets/.agents/skills/spell-save-idea/SKILL.md +1 -1
  32. package/dist/assets/.agents/skills/spell-scope/SKILL.md +1 -1
  33. package/dist/assets/.agents/skills/spell-scry/SKILL.md +1 -1
  34. package/dist/assets/.agents/skills/spell-security-review/SKILL.md +1 -1
  35. package/dist/assets/.agents/skills/spell-ship/SKILL.md +1 -1
  36. package/dist/assets/.agents/skills/spell-status/SKILL.md +1 -1
  37. package/dist/assets/.agents/skills/spell-suggest-feature/SKILL.md +1 -1
  38. package/dist/assets/.agents/skills/spell-summon-venture/SKILL.md +1 -1
  39. package/dist/assets/.agents/skills/spell-sync-pull-request/SKILL.md +1 -1
  40. package/dist/assets/.agents/skills/spell-test/SKILL.md +1 -1
  41. package/dist/assets/.agents/skills/spell-todo/SKILL.md +1 -1
  42. package/dist/assets/.agents/skills/spell-verification-ledger/SKILL.md +1 -1
  43. package/dist/assets/.arcane/governance/development-methodology.md +6 -6
  44. package/dist/assets/.arcane/governance/git-conventions.md +2 -2
  45. package/dist/assets/.arcane/governance/portable-bootstrap.md +1 -0
  46. package/dist/assets/.arcane/governance/product-excellence-standards.md +1 -1
  47. package/dist/assets/.arcane/governance/spell-authoring-standards.md +9 -1
  48. package/dist/assets/.arcane/governance/universal-agent-rules.md +5 -3
  49. package/dist/assets/.arcane/spells/spell-address-review.md +115 -0
  50. package/dist/assets/.arcane/spells/spell-adopt-docs.md +158 -0
  51. package/dist/assets/.arcane/spells/spell-arcane-version.md +87 -0
  52. package/dist/assets/.arcane/spells/spell-architect.md +117 -0
  53. package/dist/assets/.arcane/spells/spell-brainstorm.md +125 -0
  54. package/dist/assets/.arcane/spells/spell-bug.md +123 -0
  55. package/dist/assets/.arcane/spells/spell-bump.md +82 -0
  56. package/dist/assets/.arcane/spells/spell-check-drift.md +118 -0
  57. package/dist/assets/.arcane/spells/spell-close-session.md +266 -0
  58. package/dist/assets/.arcane/spells/spell-commit-work.md +279 -0
  59. package/dist/assets/.arcane/spells/spell-compliance.md +130 -0
  60. package/dist/assets/.arcane/spells/spell-create-pull-request.md +224 -0
  61. package/dist/assets/.arcane/spells/spell-document.md +84 -0
  62. package/dist/assets/.arcane/spells/spell-dotnet-expert.md +118 -0
  63. package/dist/assets/.arcane/spells/spell-eas-store-deploy.md +254 -0
  64. package/dist/assets/.arcane/spells/spell-enchant.md +386 -0
  65. package/dist/assets/.arcane/spells/spell-explain-concept.md +70 -0
  66. package/dist/assets/.arcane/spells/spell-feedback.md +138 -0
  67. package/dist/assets/.arcane/spells/spell-full-cycle.md +253 -0
  68. package/dist/assets/.arcane/spells/spell-generate-bot-icons.md +85 -0
  69. package/dist/assets/.arcane/spells/spell-implement.md +109 -0
  70. package/dist/assets/.arcane/spells/spell-make-discoverable.md +293 -0
  71. package/dist/assets/.arcane/spells/spell-manifest.md +148 -0
  72. package/dist/assets/.arcane/spells/spell-open-session.md +154 -0
  73. package/dist/assets/.arcane/spells/spell-plan.md +160 -0
  74. package/dist/assets/.arcane/spells/spell-present-arcane.md +189 -0
  75. package/dist/assets/.arcane/spells/spell-product-review.md +98 -0
  76. package/dist/assets/.arcane/spells/spell-review-batch.md +113 -0
  77. package/dist/assets/.arcane/spells/spell-review.md +142 -0
  78. package/dist/assets/.arcane/spells/spell-save-idea.md +117 -0
  79. package/dist/assets/.arcane/spells/spell-scope.md +266 -0
  80. package/dist/assets/.arcane/spells/spell-scry.md +124 -0
  81. package/dist/assets/.arcane/spells/spell-security-review.md +152 -0
  82. package/dist/assets/.arcane/spells/spell-ship.md +106 -0
  83. package/dist/assets/.arcane/spells/spell-status.md +86 -0
  84. package/dist/assets/.arcane/spells/spell-suggest-feature.md +122 -0
  85. package/dist/assets/.arcane/spells/spell-summon-venture.md +110 -0
  86. package/dist/assets/.arcane/spells/spell-sync-pull-request.md +161 -0
  87. package/dist/assets/.arcane/spells/spell-test.md +88 -0
  88. package/dist/assets/.arcane/spells/spell-todo.md +166 -0
  89. package/dist/assets/.arcane/spells/spell-verification-ledger.md +102 -0
  90. package/dist/assets/.claude/commands/spell-address-review.md +2 -2
  91. package/dist/assets/.claude/commands/spell-adopt-docs.md +2 -2
  92. package/dist/assets/.claude/commands/spell-arcane-version.md +2 -2
  93. package/dist/assets/.claude/commands/spell-architect.md +2 -2
  94. package/dist/assets/.claude/commands/spell-brainstorm.md +2 -2
  95. package/dist/assets/.claude/commands/spell-bug.md +2 -2
  96. package/dist/assets/.claude/commands/spell-bump.md +2 -2
  97. package/dist/assets/.claude/commands/spell-check-drift.md +2 -2
  98. package/dist/assets/.claude/commands/spell-close-session.md +2 -2
  99. package/dist/assets/.claude/commands/spell-commit-work.md +2 -2
  100. package/dist/assets/.claude/commands/spell-compliance.md +2 -2
  101. package/dist/assets/.claude/commands/spell-create-pull-request.md +2 -2
  102. package/dist/assets/.claude/commands/spell-document.md +2 -2
  103. package/dist/assets/.claude/commands/spell-dotnet-expert.md +2 -2
  104. package/dist/assets/.claude/commands/spell-eas-store-deploy.md +2 -2
  105. package/dist/assets/.claude/commands/spell-enchant.md +2 -2
  106. package/dist/assets/.claude/commands/spell-explain-concept.md +2 -2
  107. package/dist/assets/.claude/commands/spell-feedback.md +2 -2
  108. package/dist/assets/.claude/commands/spell-full-cycle.md +2 -2
  109. package/dist/assets/.claude/commands/spell-generate-bot-icons.md +2 -2
  110. package/dist/assets/.claude/commands/spell-implement.md +2 -2
  111. package/dist/assets/.claude/commands/spell-make-discoverable.md +2 -2
  112. package/dist/assets/.claude/commands/spell-manifest.md +2 -2
  113. package/dist/assets/.claude/commands/spell-open-session.md +2 -2
  114. package/dist/assets/.claude/commands/spell-plan.md +2 -2
  115. package/dist/assets/.claude/commands/spell-present-arcane.md +2 -2
  116. package/dist/assets/.claude/commands/spell-product-review.md +2 -2
  117. package/dist/assets/.claude/commands/spell-review-batch.md +2 -2
  118. package/dist/assets/.claude/commands/spell-review.md +2 -2
  119. package/dist/assets/.claude/commands/spell-save-idea.md +2 -2
  120. package/dist/assets/.claude/commands/spell-scope.md +2 -2
  121. package/dist/assets/.claude/commands/spell-scry.md +2 -2
  122. package/dist/assets/.claude/commands/spell-security-review.md +2 -2
  123. package/dist/assets/.claude/commands/spell-ship.md +2 -2
  124. package/dist/assets/.claude/commands/spell-status.md +2 -2
  125. package/dist/assets/.claude/commands/spell-suggest-feature.md +2 -2
  126. package/dist/assets/.claude/commands/spell-summon-venture.md +2 -2
  127. package/dist/assets/.claude/commands/spell-sync-pull-request.md +2 -2
  128. package/dist/assets/.claude/commands/spell-test.md +2 -2
  129. package/dist/assets/.claude/commands/spell-todo.md +2 -2
  130. package/dist/assets/.claude/commands/spell-verification-ledger.md +2 -2
  131. package/dist/assets/.github/instructions/agent-output.instructions.md +14 -13
  132. package/dist/assets/.github/prompts/spell-address-review.prompt.md +1 -107
  133. package/dist/assets/.github/prompts/spell-adopt-docs.prompt.md +1 -150
  134. package/dist/assets/.github/prompts/spell-arcane-version.prompt.md +1 -79
  135. package/dist/assets/.github/prompts/spell-architect.prompt.md +1 -109
  136. package/dist/assets/.github/prompts/spell-brainstorm.prompt.md +1 -117
  137. package/dist/assets/.github/prompts/spell-bug.prompt.md +1 -115
  138. package/dist/assets/.github/prompts/spell-bump.prompt.md +1 -74
  139. package/dist/assets/.github/prompts/spell-check-drift.prompt.md +1 -110
  140. package/dist/assets/.github/prompts/spell-close-session.prompt.md +1 -258
  141. package/dist/assets/.github/prompts/spell-commit-work.prompt.md +1 -270
  142. package/dist/assets/.github/prompts/spell-compliance.prompt.md +1 -122
  143. package/dist/assets/.github/prompts/spell-create-pull-request.prompt.md +1 -215
  144. package/dist/assets/.github/prompts/spell-document.prompt.md +1 -76
  145. package/dist/assets/.github/prompts/spell-dotnet-expert.prompt.md +1 -110
  146. package/dist/assets/.github/prompts/spell-eas-store-deploy.prompt.md +1 -246
  147. package/dist/assets/.github/prompts/spell-enchant.prompt.md +1 -378
  148. package/dist/assets/.github/prompts/spell-explain-concept.prompt.md +1 -62
  149. package/dist/assets/.github/prompts/spell-feedback.prompt.md +1 -130
  150. package/dist/assets/.github/prompts/spell-full-cycle.prompt.md +1 -245
  151. package/dist/assets/.github/prompts/spell-generate-bot-icons.prompt.md +1 -77
  152. package/dist/assets/.github/prompts/spell-implement.prompt.md +1 -101
  153. package/dist/assets/.github/prompts/spell-make-discoverable.prompt.md +1 -285
  154. package/dist/assets/.github/prompts/spell-manifest.prompt.md +1 -140
  155. package/dist/assets/.github/prompts/spell-open-session.prompt.md +1 -146
  156. package/dist/assets/.github/prompts/spell-plan.prompt.md +1 -152
  157. package/dist/assets/.github/prompts/spell-present-arcane.prompt.md +1 -181
  158. package/dist/assets/.github/prompts/spell-product-review.prompt.md +1 -90
  159. package/dist/assets/.github/prompts/spell-review-batch.prompt.md +1 -105
  160. package/dist/assets/.github/prompts/spell-review.prompt.md +1 -134
  161. package/dist/assets/.github/prompts/spell-save-idea.prompt.md +1 -109
  162. package/dist/assets/.github/prompts/spell-scope.prompt.md +1 -258
  163. package/dist/assets/.github/prompts/spell-scry.prompt.md +1 -116
  164. package/dist/assets/.github/prompts/spell-security-review.prompt.md +1 -144
  165. package/dist/assets/.github/prompts/spell-ship.prompt.md +1 -98
  166. package/dist/assets/.github/prompts/spell-status.prompt.md +1 -78
  167. package/dist/assets/.github/prompts/spell-suggest-feature.prompt.md +1 -114
  168. package/dist/assets/.github/prompts/spell-summon-venture.prompt.md +1 -102
  169. package/dist/assets/.github/prompts/spell-sync-pull-request.prompt.md +1 -152
  170. package/dist/assets/.github/prompts/spell-test.prompt.md +1 -80
  171. package/dist/assets/.github/prompts/spell-todo.prompt.md +1 -158
  172. package/dist/assets/.github/prompts/spell-verification-ledger.prompt.md +1 -94
  173. package/dist/index.js +1073 -246
  174. package/dist/index.js.map +1 -1
  175. package/package.json +4 -4
  176. /package/dist/assets/{.github/prompts → .arcane/spells}/_fragments/tracking-mode-declaration.md +0 -0
package/dist/index.js CHANGED
@@ -6,11 +6,11 @@ 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 dirname5, join as join17 } from "path";
9
+ import { dirname as dirname10, join as join18 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
12
  import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
13
- import { join as join5 } from "path";
13
+ import { dirname as dirname3, join as join6 } from "path";
14
14
  import chalk2 from "chalk";
15
15
 
16
16
  // src/modules/copier.ts
@@ -109,6 +109,15 @@ var VALID_TRACKING_MODES = ["internal", "external"];
109
109
  var VALID_EXTERNAL_PROVIDERS = ["ado", "github", "jira", "other"];
110
110
  var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
111
111
  var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
112
+ var VALID_INSTALL_SCOPES = ["repo", "user"];
113
+ function isValidFanoutPath(file) {
114
+ return file.length > 0 && !file.startsWith("/") && !/^[a-zA-Z]:/.test(file) && !file.includes("\\") && !file.split("/").includes("..") && !/[\r\n\0]/.test(file);
115
+ }
116
+ function isValidFanoutRecord(value) {
117
+ return isRecord(value) && Object.entries(value).every(
118
+ ([file, hash]) => isValidFanoutPath(file) && typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)
119
+ );
120
+ }
112
121
  function isValidSubjectRoot(value) {
113
122
  const invalid = value.trim() === "" || path2.isAbsolute(value) || // Windows drive-relative ("C:docs") and UNC-ish values also escape.
114
123
  /^[a-zA-Z]:/.test(value) || value.startsWith("\\\\") || value.startsWith("\\") || value.split(/[\\/]/).includes("..") || // A stored subject_root is interpolated into agent prompts downstream, so
@@ -132,6 +141,9 @@ function validateSecretsScanExcludePrefixes(value, filePath) {
132
141
  function manifestPath(targetDir) {
133
142
  return path2.join(targetDir, MANIFEST_FILE);
134
143
  }
144
+ function isRecord(value) {
145
+ return typeof value === "object" && value !== null && !Array.isArray(value);
146
+ }
135
147
  function validateTrackingFields(manifest, filePath) {
136
148
  if (manifest.tracking_mode !== void 0 && !VALID_TRACKING_MODES.includes(manifest.tracking_mode)) {
137
149
  throw new ManifestInvalidFieldError(filePath, "tracking_mode", manifest.tracking_mode);
@@ -158,6 +170,12 @@ function validateTrackingFields(manifest, filePath) {
158
170
  if (manifest.secretsScanExcludePrefixes !== void 0) {
159
171
  validateSecretsScanExcludePrefixes(manifest.secretsScanExcludePrefixes, filePath);
160
172
  }
173
+ if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
174
+ throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
175
+ }
176
+ if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
177
+ throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
178
+ }
161
179
  }
162
180
  async function readManifest(targetDir) {
163
181
  const filePath = manifestPath(targetDir);
@@ -179,9 +197,6 @@ async function readManifest(targetDir) {
179
197
  validateTrackingFields(manifest, filePath);
180
198
  return manifest;
181
199
  }
182
- function isRecord(value) {
183
- return typeof value === "object" && value !== null && !Array.isArray(value);
184
- }
185
200
  async function resolveSecretsScanExcludePrefixes(targetDir) {
186
201
  try {
187
202
  const manifest = await readManifest(targetDir);
@@ -508,31 +523,39 @@ var COMPONENTS = [
508
523
  // ─── Spells ────────────────────────────────────────────────────────────────
509
524
  // Split from the former monolithic `spell-prompts` + `claude-commands` pair
510
525
  // so a profile can select spells by capability (see ARC-0NN). Grouping lives
511
- // here and only here -- every spell file stays flat and unrenamed on disk.
526
+ // here and only here -- every spell file stays flat on disk; the one
527
+ // sanctioned relocation is ARC-045's canonical move (CS-03).
512
528
  //
513
- // Each component carries ALL THREE client formats of the same spell: the
514
- // Copilot prompt (.github/prompts/*.prompt.md), the Claude Code wrapper
529
+ // Each component carries, per spell, the canonical source and ALL THREE
530
+ // generated client shims (ARC-045 / CS-03): the client-neutral source
531
+ // (.arcane/spells/*.md, always listed first), the Copilot prompt shim
532
+ // (.github/prompts/*.prompt.md), the Claude Code command stub
515
533
  // (.claude/commands/*.md), and the Codex skill (.agents/skills/*/SKILL.md,
516
534
  // CS-01). They are never independently selectable -- every profile that
517
535
  // wanted one always wanted the others -- and pairing them here makes it
518
- // structurally impossible for the formats of one spell to drift apart
519
- // across profiles.
536
+ // structurally impossible for a spell's source and its shims to drift
537
+ // apart across profiles.
520
538
  {
521
539
  name: "spells-session",
522
540
  description: "Session lifecycle \u2014 open, close, commit, status, version",
523
541
  files: [
542
+ ".arcane/spells/spell-open-session.md",
524
543
  ".github/prompts/spell-open-session.prompt.md",
525
544
  ".claude/commands/spell-open-session.md",
526
545
  ".agents/skills/spell-open-session/SKILL.md",
546
+ ".arcane/spells/spell-close-session.md",
527
547
  ".github/prompts/spell-close-session.prompt.md",
528
548
  ".claude/commands/spell-close-session.md",
529
549
  ".agents/skills/spell-close-session/SKILL.md",
550
+ ".arcane/spells/spell-commit-work.md",
530
551
  ".github/prompts/spell-commit-work.prompt.md",
531
552
  ".claude/commands/spell-commit-work.md",
532
553
  ".agents/skills/spell-commit-work/SKILL.md",
554
+ ".arcane/spells/spell-status.md",
533
555
  ".github/prompts/spell-status.prompt.md",
534
556
  ".claude/commands/spell-status.md",
535
557
  ".agents/skills/spell-status/SKILL.md",
558
+ ".arcane/spells/spell-arcane-version.md",
536
559
  ".github/prompts/spell-arcane-version.prompt.md",
537
560
  ".claude/commands/spell-arcane-version.md",
538
561
  ".agents/skills/spell-arcane-version/SKILL.md"
@@ -542,27 +565,35 @@ var COMPONENTS = [
542
565
  name: "spells-capture",
543
566
  description: "Idea, todo, feedback and explanation capture",
544
567
  files: [
568
+ ".arcane/spells/spell-save-idea.md",
545
569
  ".github/prompts/spell-save-idea.prompt.md",
546
570
  ".claude/commands/spell-save-idea.md",
547
571
  ".agents/skills/spell-save-idea/SKILL.md",
572
+ ".arcane/spells/spell-todo.md",
548
573
  ".github/prompts/spell-todo.prompt.md",
549
574
  ".claude/commands/spell-todo.md",
550
575
  ".agents/skills/spell-todo/SKILL.md",
576
+ ".arcane/spells/spell-feedback.md",
551
577
  ".github/prompts/spell-feedback.prompt.md",
552
578
  ".claude/commands/spell-feedback.md",
553
579
  ".agents/skills/spell-feedback/SKILL.md",
580
+ ".arcane/spells/spell-suggest-feature.md",
554
581
  ".github/prompts/spell-suggest-feature.prompt.md",
555
582
  ".claude/commands/spell-suggest-feature.md",
556
583
  ".agents/skills/spell-suggest-feature/SKILL.md",
584
+ ".arcane/spells/spell-document.md",
557
585
  ".github/prompts/spell-document.prompt.md",
558
586
  ".claude/commands/spell-document.md",
559
587
  ".agents/skills/spell-document/SKILL.md",
588
+ ".arcane/spells/spell-verification-ledger.md",
560
589
  ".github/prompts/spell-verification-ledger.prompt.md",
561
590
  ".claude/commands/spell-verification-ledger.md",
562
591
  ".agents/skills/spell-verification-ledger/SKILL.md",
592
+ ".arcane/spells/spell-brainstorm.md",
563
593
  ".github/prompts/spell-brainstorm.prompt.md",
564
594
  ".claude/commands/spell-brainstorm.md",
565
595
  ".agents/skills/spell-brainstorm/SKILL.md",
596
+ ".arcane/spells/spell-explain-concept.md",
566
597
  ".github/prompts/spell-explain-concept.prompt.md",
567
598
  ".claude/commands/spell-explain-concept.md",
568
599
  ".agents/skills/spell-explain-concept/SKILL.md"
@@ -572,12 +603,15 @@ var COMPONENTS = [
572
603
  name: "spells-delivery",
573
604
  description: "Pull-request lifecycle \u2014 open a PR, sync a stale one, respond to review feedback",
574
605
  files: [
606
+ ".arcane/spells/spell-create-pull-request.md",
575
607
  ".github/prompts/spell-create-pull-request.prompt.md",
576
608
  ".claude/commands/spell-create-pull-request.md",
577
609
  ".agents/skills/spell-create-pull-request/SKILL.md",
610
+ ".arcane/spells/spell-sync-pull-request.md",
578
611
  ".github/prompts/spell-sync-pull-request.prompt.md",
579
612
  ".claude/commands/spell-sync-pull-request.md",
580
613
  ".agents/skills/spell-sync-pull-request/SKILL.md",
614
+ ".arcane/spells/spell-address-review.md",
581
615
  ".github/prompts/spell-address-review.prompt.md",
582
616
  ".claude/commands/spell-address-review.md",
583
617
  ".agents/skills/spell-address-review/SKILL.md"
@@ -587,9 +621,11 @@ var COMPONENTS = [
587
621
  name: "spells-review",
588
622
  description: "Adversarial code review \u2014 requires source and tests, so excluded from docs-only profiles",
589
623
  files: [
624
+ ".arcane/spells/spell-review.md",
590
625
  ".github/prompts/spell-review.prompt.md",
591
626
  ".claude/commands/spell-review.md",
592
627
  ".agents/skills/spell-review/SKILL.md",
628
+ ".arcane/spells/spell-review-batch.md",
593
629
  ".github/prompts/spell-review-batch.prompt.md",
594
630
  ".claude/commands/spell-review-batch.md",
595
631
  ".agents/skills/spell-review-batch/SKILL.md"
@@ -599,15 +635,19 @@ var COMPONENTS = [
599
635
  name: "spells-planning",
600
636
  description: "PRD, architecture, scoping and product review",
601
637
  files: [
638
+ ".arcane/spells/spell-plan.md",
602
639
  ".github/prompts/spell-plan.prompt.md",
603
640
  ".claude/commands/spell-plan.md",
604
641
  ".agents/skills/spell-plan/SKILL.md",
642
+ ".arcane/spells/spell-architect.md",
605
643
  ".github/prompts/spell-architect.prompt.md",
606
644
  ".claude/commands/spell-architect.md",
607
645
  ".agents/skills/spell-architect/SKILL.md",
646
+ ".arcane/spells/spell-scope.md",
608
647
  ".github/prompts/spell-scope.prompt.md",
609
648
  ".claude/commands/spell-scope.md",
610
649
  ".agents/skills/spell-scope/SKILL.md",
650
+ ".arcane/spells/spell-product-review.md",
611
651
  ".github/prompts/spell-product-review.prompt.md",
612
652
  ".claude/commands/spell-product-review.md",
613
653
  ".agents/skills/spell-product-review/SKILL.md"
@@ -617,45 +657,59 @@ var COMPONENTS = [
617
657
  name: "spells-build",
618
658
  description: "Code and product delivery \u2014 implementation, tests, stack experts, release, deployment, asset tooling",
619
659
  files: [
660
+ ".arcane/spells/spell-implement.md",
620
661
  ".github/prompts/spell-implement.prompt.md",
621
662
  ".claude/commands/spell-implement.md",
622
663
  ".agents/skills/spell-implement/SKILL.md",
664
+ ".arcane/spells/spell-test.md",
623
665
  ".github/prompts/spell-test.prompt.md",
624
666
  ".claude/commands/spell-test.md",
625
667
  ".agents/skills/spell-test/SKILL.md",
668
+ ".arcane/spells/spell-full-cycle.md",
626
669
  ".github/prompts/spell-full-cycle.prompt.md",
627
670
  ".claude/commands/spell-full-cycle.md",
628
671
  ".agents/skills/spell-full-cycle/SKILL.md",
672
+ ".arcane/spells/spell-bug.md",
629
673
  ".github/prompts/spell-bug.prompt.md",
630
674
  ".claude/commands/spell-bug.md",
631
675
  ".agents/skills/spell-bug/SKILL.md",
676
+ ".arcane/spells/spell-bump.md",
632
677
  ".github/prompts/spell-bump.prompt.md",
633
678
  ".claude/commands/spell-bump.md",
634
679
  ".agents/skills/spell-bump/SKILL.md",
680
+ ".arcane/spells/spell-dotnet-expert.md",
635
681
  ".github/prompts/spell-dotnet-expert.prompt.md",
636
682
  ".claude/commands/spell-dotnet-expert.md",
637
683
  ".agents/skills/spell-dotnet-expert/SKILL.md",
684
+ ".arcane/spells/spell-security-review.md",
638
685
  ".github/prompts/spell-security-review.prompt.md",
639
686
  ".claude/commands/spell-security-review.md",
640
687
  ".agents/skills/spell-security-review/SKILL.md",
688
+ ".arcane/spells/spell-ship.md",
641
689
  ".github/prompts/spell-ship.prompt.md",
642
690
  ".claude/commands/spell-ship.md",
643
691
  ".agents/skills/spell-ship/SKILL.md",
692
+ ".arcane/spells/spell-enchant.md",
644
693
  ".github/prompts/spell-enchant.prompt.md",
645
694
  ".claude/commands/spell-enchant.md",
646
695
  ".agents/skills/spell-enchant/SKILL.md",
696
+ ".arcane/spells/spell-generate-bot-icons.md",
647
697
  ".github/prompts/spell-generate-bot-icons.prompt.md",
648
698
  ".claude/commands/spell-generate-bot-icons.md",
649
699
  ".agents/skills/spell-generate-bot-icons/SKILL.md",
700
+ ".arcane/spells/spell-make-discoverable.md",
650
701
  ".github/prompts/spell-make-discoverable.prompt.md",
651
702
  ".claude/commands/spell-make-discoverable.md",
652
703
  ".agents/skills/spell-make-discoverable/SKILL.md",
704
+ ".arcane/spells/spell-scry.md",
653
705
  ".github/prompts/spell-scry.prompt.md",
654
706
  ".claude/commands/spell-scry.md",
655
707
  ".agents/skills/spell-scry/SKILL.md",
708
+ ".arcane/spells/spell-eas-store-deploy.md",
656
709
  ".github/prompts/spell-eas-store-deploy.prompt.md",
657
710
  ".claude/commands/spell-eas-store-deploy.md",
658
711
  ".agents/skills/spell-eas-store-deploy/SKILL.md",
712
+ ".arcane/spells/spell-compliance.md",
659
713
  ".github/prompts/spell-compliance.prompt.md",
660
714
  ".claude/commands/spell-compliance.md",
661
715
  ".agents/skills/spell-compliance/SKILL.md"
@@ -665,6 +719,7 @@ var COMPONENTS = [
665
719
  name: "spells-docs",
666
720
  description: "Documentation and records workflows \u2014 adopt an existing document tree",
667
721
  files: [
722
+ ".arcane/spells/spell-adopt-docs.md",
668
723
  ".github/prompts/spell-adopt-docs.prompt.md",
669
724
  ".claude/commands/spell-adopt-docs.md",
670
725
  ".agents/skills/spell-adopt-docs/SKILL.md"
@@ -674,9 +729,11 @@ var COMPONENTS = [
674
729
  name: "spells-venture",
675
730
  description: "Hub-only venture management",
676
731
  files: [
732
+ ".arcane/spells/spell-summon-venture.md",
677
733
  ".github/prompts/spell-summon-venture.prompt.md",
678
734
  ".claude/commands/spell-summon-venture.md",
679
735
  ".agents/skills/spell-summon-venture/SKILL.md",
736
+ ".arcane/spells/spell-manifest.md",
680
737
  ".github/prompts/spell-manifest.prompt.md",
681
738
  ".claude/commands/spell-manifest.md",
682
739
  ".agents/skills/spell-manifest/SKILL.md"
@@ -686,9 +743,11 @@ var COMPONENTS = [
686
743
  name: "spells-meta",
687
744
  description: "Arcane-about-Arcane \u2014 presentation and documentation drift detection",
688
745
  files: [
746
+ ".arcane/spells/spell-present-arcane.md",
689
747
  ".github/prompts/spell-present-arcane.prompt.md",
690
748
  ".claude/commands/spell-present-arcane.md",
691
749
  ".agents/skills/spell-present-arcane/SKILL.md",
750
+ ".arcane/spells/spell-check-drift.md",
692
751
  ".github/prompts/spell-check-drift.prompt.md",
693
752
  ".claude/commands/spell-check-drift.md",
694
753
  ".agents/skills/spell-check-drift/SKILL.md"
@@ -778,6 +837,400 @@ function listProfiles() {
778
837
  }));
779
838
  }
780
839
 
840
+ // 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";
843
+ import { homedir } from "os";
844
+ import { dirname, join, resolve } from "path";
845
+
846
+ // src/modules/spell-compiler.ts
847
+ import { parse as parseYaml } from "yaml";
848
+ var MissingFrontmatterError = class extends Error {
849
+ constructor(message) {
850
+ super(message);
851
+ this.name = "MissingFrontmatterError";
852
+ }
853
+ };
854
+ var InvalidSkillNameError = class extends Error {
855
+ constructor(message) {
856
+ super(message);
857
+ this.name = "InvalidSkillNameError";
858
+ }
859
+ };
860
+ var CANONICAL_SPELLS_DIR = ".arcane/spells";
861
+ function canonicalSpellPath(id) {
862
+ return `${CANONICAL_SPELLS_DIR}/${id}.md`;
863
+ }
864
+ var CLIENT_SHIM_PATH_PATTERNS = [
865
+ /^\.github\/prompts\/spell-[a-z0-9-]+\.prompt\.md$/,
866
+ /^\.claude\/commands\/spell-[a-z0-9-]+\.md$/,
867
+ /^\.agents\/skills\/spell-[a-z0-9-]+\/SKILL\.md$/
868
+ ];
869
+ function isClientShimPath(path4) {
870
+ const normalized = path4.replace(/\\/g, "/").replace(/^\.\//, "");
871
+ return CLIENT_SHIM_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
872
+ }
873
+ var FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
874
+ function parsePromptFrontmatter(content) {
875
+ const match = content.match(FRONTMATTER_PATTERN);
876
+ if (!match) {
877
+ throw new MissingFrontmatterError(
878
+ "Prompt file has no YAML frontmatter block (expected a leading --- ... --- block)."
879
+ );
880
+ }
881
+ const raw = parseYaml(match[1]);
882
+ const name = raw?.["name"];
883
+ const description = raw?.["description"];
884
+ if (typeof name !== "string" || name.trim() === "") {
885
+ throw new MissingFrontmatterError('Prompt frontmatter is missing a "name" field.');
886
+ }
887
+ if (typeof description !== "string" || description.trim() === "") {
888
+ throw new MissingFrontmatterError('Prompt frontmatter is missing a "description" field.');
889
+ }
890
+ const claudeDescription = raw?.["claude_description"];
891
+ return {
892
+ name,
893
+ description,
894
+ claudeDescription: typeof claudeDescription === "string" ? claudeDescription : void 0
895
+ };
896
+ }
897
+ var SPELL_NAME_PREFIX = /^Spell\s*—\s*/;
898
+ function deriveStubTitle(name) {
899
+ return name.replace(SPELL_NAME_PREFIX, "").trim();
900
+ }
901
+ function renderClaudeCommandStub(id, frontmatter, canonicalRef = canonicalSpellPath(id)) {
902
+ const title = deriveStubTitle(frontmatter.name);
903
+ const description = frontmatter.claudeDescription ?? frontmatter.description;
904
+ const promptPath = canonicalRef;
905
+ return `---
906
+ description: ${description}
907
+ ---
908
+
909
+ # ${title}
910
+
911
+ Invoke the Arcane \`${id}\` spell workflow.
912
+
913
+ See the full prompt at \`${promptPath}\` for the complete workflow definition.
914
+
915
+ ---
916
+
917
+ @${promptPath}
918
+ `;
919
+ }
920
+ var SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
921
+ function renderCodexSkill(id, frontmatter, canonicalPath) {
922
+ if (!SKILL_NAME_PATTERN.test(id)) {
923
+ throw new InvalidSkillNameError(
924
+ `Skill id "${id}" must match ${SKILL_NAME_PATTERN} (lowercase letters, digits, hyphens only).`
925
+ );
926
+ }
927
+ const description = frontmatter.claudeDescription ?? frontmatter.description;
928
+ return `---
929
+ name: ${id}
930
+ description: ${description}
931
+ ---
932
+
933
+ This skill is the Arcane \`${id}\` spell. Read \`${canonicalPath}\` and follow it as the complete workflow.
934
+ `;
935
+ }
936
+
937
+ // src/modules/user-tier.ts
938
+ var USER_TIER_DIR_NAME = ".arcane";
939
+ var USER_TIER_SPELLS_DIR = "spells";
940
+ function resolveHomeDir() {
941
+ return homedir();
942
+ }
943
+ function userTierRoot(homeDir = resolveHomeDir()) {
944
+ return join(homeDir, USER_TIER_DIR_NAME);
945
+ }
946
+ var USER_TIER_COMPONENTS = SPELL_COMPONENT_NAMES;
947
+ var CANONICAL_SPELL_PATH_PATTERN = /^\.arcane\/spells\/(spell-[a-z0-9-]+)\.md$/;
948
+ var STORE_SPELL_PATH_PATTERN = /^spells\/(spell-[a-z0-9-]+)\.md$/;
949
+ function storeSpellPath(id) {
950
+ return `${USER_TIER_SPELLS_DIR}/${id}.md`;
951
+ }
952
+ function spellIdFromCanonicalPath(path4) {
953
+ return path4.replace(/\\/g, "/").match(CANONICAL_SPELL_PATH_PATTERN)?.[1];
954
+ }
955
+ function spellIdFromStorePath(path4) {
956
+ return path4.replace(/\\/g, "/").match(STORE_SPELL_PATH_PATTERN)?.[1];
957
+ }
958
+ function absoluteStoreSpellPath(storeRoot, id) {
959
+ return resolve(storeRoot, storeSpellPath(id)).replace(/\\/g, "/");
960
+ }
961
+ function componentForScope(component, scope) {
962
+ if (scope === "repo") return component;
963
+ const files = [];
964
+ const sourceOverrides = {};
965
+ for (const file of component.files) {
966
+ const id = spellIdFromCanonicalPath(file);
967
+ if (id === void 0) continue;
968
+ const installed = storeSpellPath(id);
969
+ files.push(installed);
970
+ sourceOverrides[installed] = component.sourceOverrides?.[file] ?? file;
971
+ }
972
+ return { name: component.name, description: component.description, files, sourceOverrides };
973
+ }
974
+ function spellIdsInStore(components) {
975
+ const ids = /* @__PURE__ */ new Set();
976
+ for (const component of components) {
977
+ for (const file of component.files) {
978
+ const id = spellIdFromStorePath(file);
979
+ if (id !== void 0) ids.add(id);
980
+ }
981
+ }
982
+ return [...ids].sort();
983
+ }
984
+ var USER_FANOUT_PATHS = {
985
+ codex: (id) => `.agents/skills/${id}/SKILL.md`,
986
+ claude: (id) => `.claude/commands/${id}.md`
987
+ };
988
+ var FANOUT_CLIENT_LABELS = {
989
+ codex: "Codex/Copilot skills",
990
+ claude: "Claude Code commands"
991
+ };
992
+ function clientOfFanoutPath(relativePath) {
993
+ const normalized = relativePath.replace(/\\/g, "/");
994
+ if (normalized.startsWith(".agents/skills/")) return "codex";
995
+ if (normalized.startsWith(".claude/commands/")) return "claude";
996
+ return void 0;
997
+ }
998
+ async function readSpellSource(storeRoot, id, fallbackDir) {
999
+ for (const candidate of [
1000
+ join(storeRoot, storeSpellPath(id)),
1001
+ ...fallbackDir ? [join(fallbackDir, canonicalSpellPath(id))] : []
1002
+ ]) {
1003
+ try {
1004
+ return await readFile3(candidate, "utf-8");
1005
+ } catch (err) {
1006
+ if (err.code !== "ENOENT") throw err;
1007
+ }
1008
+ }
1009
+ return void 0;
1010
+ }
1011
+ async function renderUserFanout(storeRoot, spellIds, fallbackDir) {
1012
+ const files = [];
1013
+ const missing = [];
1014
+ for (const id of [...spellIds].sort()) {
1015
+ const canonical = await readSpellSource(storeRoot, id, fallbackDir);
1016
+ if (canonical === void 0) {
1017
+ missing.push(id);
1018
+ continue;
1019
+ }
1020
+ const frontmatter = parsePromptFrontmatter(canonical);
1021
+ const absolutePath = absoluteStoreSpellPath(storeRoot, id);
1022
+ files.push({
1023
+ relativePath: USER_FANOUT_PATHS.codex(id),
1024
+ client: "codex",
1025
+ content: renderCodexSkill(id, frontmatter, absolutePath)
1026
+ });
1027
+ files.push({
1028
+ relativePath: USER_FANOUT_PATHS.claude(id),
1029
+ client: "claude",
1030
+ content: renderClaudeCommandStub(id, frontmatter, absolutePath)
1031
+ });
1032
+ }
1033
+ return { files, missing };
1034
+ }
1035
+ async function classifyTarget(path4) {
1036
+ try {
1037
+ const stats = await lstat(path4);
1038
+ return stats.isFile() ? "file" : "other";
1039
+ } catch (err) {
1040
+ if (err.code === "ENOENT") return "absent";
1041
+ throw err;
1042
+ }
1043
+ }
1044
+ function sha256(content) {
1045
+ return createHash2("sha256").update(content, "utf-8").digest("hex");
1046
+ }
1047
+ function perSpellDirectory(homeDir, relativePath) {
1048
+ if (clientOfFanoutPath(relativePath) !== "codex") return void 0;
1049
+ return dirname(join(homeDir, relativePath));
1050
+ }
1051
+ async function removeDirectoryIfEmpty(dir) {
1052
+ try {
1053
+ const entries = await readdir2(dir);
1054
+ if (entries.length === 0) await rmdir(dir);
1055
+ } catch {
1056
+ }
1057
+ }
1058
+ async function syncUserTierFanout(options) {
1059
+ const rendered = await renderUserFanout(options.storeRoot, options.spellIds, options.fallbackDir);
1060
+ const desired = rendered.files;
1061
+ const previous = options.previous ?? {};
1062
+ const dryRun = Boolean(options.dryRun);
1063
+ const record = {};
1064
+ const outcomes = [];
1065
+ const desiredPaths = new Set(desired.map((f) => f.relativePath));
1066
+ for (const id of rendered.missing) {
1067
+ for (const client of ["codex", "claude"]) {
1068
+ const relativePath = USER_FANOUT_PATHS[client](id);
1069
+ desiredPaths.add(relativePath);
1070
+ if (previous[relativePath] !== void 0) record[relativePath] = previous[relativePath];
1071
+ outcomes.push({ relativePath, status: "unrenderable" });
1072
+ }
1073
+ }
1074
+ for (const file of desired) {
1075
+ const target = join(options.homeDir, file.relativePath);
1076
+ const recorded = previous[file.relativePath];
1077
+ const newHash = sha256(file.content);
1078
+ const state = await classifyTarget(target);
1079
+ if (state === "absent") {
1080
+ if (!dryRun) {
1081
+ await mkdir2(dirname(target), { recursive: true });
1082
+ await writeFile3(target, file.content, "utf-8");
1083
+ }
1084
+ record[file.relativePath] = newHash;
1085
+ outcomes.push({ relativePath: file.relativePath, status: "written" });
1086
+ continue;
1087
+ }
1088
+ if (state === "other" || recorded === void 0) {
1089
+ outcomes.push({ relativePath: file.relativePath, status: "collision" });
1090
+ continue;
1091
+ }
1092
+ const currentHash = await hashFile(target);
1093
+ if (currentHash !== recorded) {
1094
+ record[file.relativePath] = recorded;
1095
+ outcomes.push({ relativePath: file.relativePath, status: "customized" });
1096
+ continue;
1097
+ }
1098
+ if (currentHash === newHash) {
1099
+ record[file.relativePath] = newHash;
1100
+ outcomes.push({ relativePath: file.relativePath, status: "unchanged" });
1101
+ continue;
1102
+ }
1103
+ if (!dryRun) await writeFile3(target, file.content, "utf-8");
1104
+ record[file.relativePath] = newHash;
1105
+ outcomes.push({ relativePath: file.relativePath, status: "written" });
1106
+ }
1107
+ for (const [relativePath, recorded] of Object.entries(previous)) {
1108
+ if (desiredPaths.has(relativePath)) continue;
1109
+ const target = join(options.homeDir, relativePath);
1110
+ const state = await classifyTarget(target);
1111
+ if (state === "absent") {
1112
+ outcomes.push({ relativePath, status: "gone" });
1113
+ continue;
1114
+ }
1115
+ if (state === "other" || await hashFile(target) !== recorded) {
1116
+ record[relativePath] = recorded;
1117
+ outcomes.push({ relativePath, status: "kept-edited" });
1118
+ continue;
1119
+ }
1120
+ if (!dryRun) {
1121
+ await rm(target, { force: true });
1122
+ const spellDir = perSpellDirectory(options.homeDir, relativePath);
1123
+ if (spellDir !== void 0) await removeDirectoryIfEmpty(spellDir);
1124
+ }
1125
+ outcomes.push({ relativePath, status: "pruned" });
1126
+ }
1127
+ return { record, outcomes };
1128
+ }
1129
+ function summarizeFanout(outcomes) {
1130
+ const counts = {
1131
+ written: 0,
1132
+ unchanged: 0,
1133
+ customized: 0,
1134
+ collision: 0,
1135
+ pruned: 0,
1136
+ "kept-edited": 0,
1137
+ gone: 0,
1138
+ unrenderable: 0
1139
+ };
1140
+ for (const outcome of outcomes) counts[outcome.status]++;
1141
+ return counts;
1142
+ }
1143
+ function describeFanoutOutcomes(outcomes, dryRun = false) {
1144
+ const counts = summarizeFanout(outcomes);
1145
+ const lines = [];
1146
+ const prefix = dryRun ? "[dry-run] Would " : "";
1147
+ const byClient = (status) => {
1148
+ const n = { codex: 0, claude: 0 };
1149
+ for (const o of outcomes) {
1150
+ if (o.status !== status) continue;
1151
+ const client = clientOfFanoutPath(o.relativePath);
1152
+ if (client !== void 0) n[client]++;
1153
+ }
1154
+ return n;
1155
+ };
1156
+ const paths = (status) => outcomes.filter((o) => o.status === status).map((o) => ` ~/${o.relativePath}`).join("\n");
1157
+ if (counts.written > 0) {
1158
+ const n = byClient("written");
1159
+ lines.push(
1160
+ `${prefix}${dryRun ? "write" : "Wrote"} ${counts.written} client file(s): ${n.codex} ${FANOUT_CLIENT_LABELS.codex}, ${n.claude} ${FANOUT_CLIENT_LABELS.claude}.`
1161
+ );
1162
+ }
1163
+ if (counts.pruned > 0) {
1164
+ lines.push(`${prefix}${dryRun ? "remove" : "Removed"} ${counts.pruned} client file(s) no longer needed.`);
1165
+ }
1166
+ if (counts.customized > 0) {
1167
+ lines.push(
1168
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts.customized} customized client file(s) untouched (edited since Arcane wrote them):
1169
+ ${paths("customized")}
1170
+ Delete one to have it regenerated by the next \`spell update --user\`.`
1171
+ );
1172
+ }
1173
+ if (counts.collision > 0) {
1174
+ lines.push(
1175
+ `! ${dryRun ? "Would leave" : "Left"} ${counts.collision} existing path(s) alone (not written by Arcane \u2014 a skill or command of the same name, a directory or a link already exists there):
1176
+ ${paths("collision")}
1177
+ Move or rename it and run \`spell update --user\` to install Arcane's.`
1178
+ );
1179
+ }
1180
+ if (counts.unrenderable > 0) {
1181
+ lines.push(
1182
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts.unrenderable} client file(s) whose spell source is missing from the store and from this CLI (left as they are):
1183
+ ${paths("unrenderable")}
1184
+ Restore the spell with \`spell update --user\`, or delete these files if the spell is no longer shipped.`
1185
+ );
1186
+ }
1187
+ if (counts["kept-edited"] > 0) {
1188
+ lines.push(
1189
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts["kept-edited"]} edited client file(s) that Arcane no longer needs (delete them yourself if unwanted):
1190
+ ${paths("kept-edited")}`
1191
+ );
1192
+ }
1193
+ return lines;
1194
+ }
1195
+ async function inspectUserTierFanout(homeDir, record) {
1196
+ const health = {
1197
+ total: 0,
1198
+ byClient: { codex: 0, claude: 0 },
1199
+ missing: [],
1200
+ customized: []
1201
+ };
1202
+ for (const [relativePath, recorded] of Object.entries(record ?? {})) {
1203
+ health.total++;
1204
+ const client = clientOfFanoutPath(relativePath);
1205
+ if (client !== void 0) health.byClient[client]++;
1206
+ const target = join(homeDir, relativePath);
1207
+ const state = await classifyTarget(target);
1208
+ if (state === "absent") {
1209
+ health.missing.push(relativePath);
1210
+ continue;
1211
+ }
1212
+ if (state === "other" || await hashFile(target) !== recorded) health.customized.push(relativePath);
1213
+ }
1214
+ return health;
1215
+ }
1216
+ function isUserTierStore(targetDir) {
1217
+ return resolve(targetDir) === resolve(userTierRoot());
1218
+ }
1219
+ function resolveInstallScope(targetDir, manifestScope, userFlag) {
1220
+ if (userFlag) return { scope: "user", misplacedUserManifest: false };
1221
+ if (manifestScope !== "user") return { scope: "repo", misplacedUserManifest: false };
1222
+ return isUserTierStore(targetDir) ? { scope: "user", misplacedUserManifest: false } : { scope: "repo", misplacedUserManifest: true };
1223
+ }
1224
+ function misplacedUserManifestMessage(targetDir) {
1225
+ return `This .arcane.json belongs to the user tier (scope: "user"), but ${targetDir} is not the store (${userTierRoot()}). Run the command with --user to operate on the store, or from the store directory itself.`;
1226
+ }
1227
+ var VSCODE_USER_TIER_NOTE = [
1228
+ "VS Code Copilot: no settings change is needed \u2014 Copilot discovers ~/.agents/skills by default",
1229
+ "(chat.useAgentSkills, on by default) and lists each spell under /. Reload the window after installing.",
1230
+ "VS Code has deprecated chat.promptFilesLocations; the user tier does not use it."
1231
+ ];
1232
+ var CLAUDE_PRECEDENCE_NOTE = "Claude Code runs a personal command over a project command of the same name, so /spell-* in a repository that still carries its own shims now runs the user tier's copy.";
1233
+
781
1234
  // src/modules/banner.ts
782
1235
  import chalk from "chalk";
783
1236
  import gradient from "gradient-string";
@@ -800,7 +1253,7 @@ var WORDMARK = [
800
1253
  var INDENT = " ";
801
1254
  var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
802
1255
  function sleep(ms) {
803
- return new Promise((resolve4) => setTimeout(resolve4, ms));
1256
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
804
1257
  }
805
1258
  function isAnimatable() {
806
1259
  return !!process.stdout.isTTY && process.env["TERM"] !== "dumb" && process.env["CI"] !== "true" && process.env["ARCANE_DISABLE_ANIMATION"] !== "true" && (process.stdout.columns ?? 80) >= LOCKUP_WIDTH;
@@ -918,8 +1371,8 @@ function printWarning(message) {
918
1371
 
919
1372
  // src/modules/agents.ts
920
1373
  import { select, checkbox, Separator } from "@inquirer/prompts";
921
- import { mkdir as mkdir3, writeFile as writeFile5, copyFile as copyFile2 } from "fs/promises";
922
- import { join as join3 } from "path";
1374
+ import { mkdir as mkdir4, writeFile as writeFile6, copyFile as copyFile2 } from "fs/promises";
1375
+ import { join as join4 } from "path";
923
1376
  import { stringify } from "yaml";
924
1377
 
925
1378
  // src/config/agent-roles.ts
@@ -1154,8 +1607,8 @@ async function applyNamingStrategy(strategy, roleIds) {
1154
1607
  }
1155
1608
 
1156
1609
  // src/modules/agent-loader.ts
1157
- import { readFile as readFile3, readdir as readdir2, access as access2 } from "fs/promises";
1158
- import { join } from "path";
1610
+ import { readFile as readFile4, readdir as readdir3, access as access2 } from "fs/promises";
1611
+ import { join as join2 } from "path";
1159
1612
  import { parse } from "yaml";
1160
1613
 
1161
1614
  // src/modules/agent-schema.ts
@@ -1335,7 +1788,7 @@ var AgentRosterNotFoundError = class extends Error {
1335
1788
  this.name = "AgentRosterNotFoundError";
1336
1789
  }
1337
1790
  };
1338
- function parseYaml(content, source) {
1791
+ function parseYaml2(content, source) {
1339
1792
  try {
1340
1793
  return parse(content);
1341
1794
  } catch (err) {
@@ -1344,44 +1797,44 @@ function parseYaml(content, source) {
1344
1797
  }
1345
1798
  }
1346
1799
  async function loadAgentDefinition(agentsDir, id) {
1347
- const filePath = join(agentsDir, `${id}.yaml`);
1800
+ const filePath = join2(agentsDir, `${id}.yaml`);
1348
1801
  let content;
1349
1802
  try {
1350
- content = await readFile3(filePath, "utf8");
1803
+ content = await readFile4(filePath, "utf8");
1351
1804
  } catch {
1352
1805
  throw new AgentDefinitionNotFoundError(id, agentsDir);
1353
1806
  }
1354
- return validateAgentDefinition(parseYaml(content, filePath), filePath);
1807
+ return validateAgentDefinition(parseYaml2(content, filePath), filePath);
1355
1808
  }
1356
1809
  async function loadRoster(targetDir) {
1357
- const rosterPath = join(targetDir, ".arcane", "agents.yaml");
1810
+ const rosterPath = join2(targetDir, ".arcane", "agents.yaml");
1358
1811
  let content;
1359
1812
  try {
1360
- content = await readFile3(rosterPath, "utf8");
1813
+ content = await readFile4(rosterPath, "utf8");
1361
1814
  } catch {
1362
1815
  throw new AgentRosterNotFoundError();
1363
1816
  }
1364
- return validateAgentRoster(parseYaml(content, rosterPath), rosterPath);
1817
+ return validateAgentRoster(parseYaml2(content, rosterPath), rosterPath);
1365
1818
  }
1366
1819
  async function rosterExists(targetDir) {
1367
1820
  try {
1368
- await access2(join(targetDir, ".arcane", "agents.yaml"));
1821
+ await access2(join2(targetDir, ".arcane", "agents.yaml"));
1369
1822
  return true;
1370
1823
  } catch {
1371
1824
  return false;
1372
1825
  }
1373
1826
  }
1374
1827
  function projectAgentsDir(targetDir) {
1375
- return join(targetDir, ".arcane", "agents");
1828
+ return join2(targetDir, ".arcane", "agents");
1376
1829
  }
1377
1830
 
1378
1831
  // src/modules/agent-generator.ts
1379
- import { writeFile as writeFile4, mkdir as mkdir2 } from "fs/promises";
1380
- import { join as join2 } from "path";
1381
- import { homedir } from "os";
1832
+ import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
1833
+ import { join as join3 } from "path";
1834
+ import { homedir as homedir2 } from "os";
1382
1835
 
1383
1836
  // src/modules/merger.ts
1384
- import { readFile as readFile4, writeFile as writeFile3, rm } from "fs/promises";
1837
+ import { readFile as readFile5, writeFile as writeFile4, rm as rm2 } from "fs/promises";
1385
1838
  import path3 from "path";
1386
1839
  var MARKER_START = "<!-- arcane:start -->";
1387
1840
  var MARKER_END = "<!-- arcane:end -->";
@@ -1396,7 +1849,7 @@ async function mergeIntoFile(targetDir, relativePath, content, opts = {}) {
1396
1849
  const destPath = path3.resolve(targetDir, relativePath);
1397
1850
  let existing;
1398
1851
  try {
1399
- existing = await readFile4(destPath, "utf8");
1852
+ existing = await readFile5(destPath, "utf8");
1400
1853
  } catch (err) {
1401
1854
  const e = err;
1402
1855
  if (e.code === "ENOENT") {
@@ -1412,7 +1865,7 @@ ${content}
1412
1865
  ${MARKER_END}
1413
1866
  `;
1414
1867
  if (!opts.dryRun) {
1415
- await writeFile3(destPath, newContent, "utf8");
1868
+ await writeFile4(destPath, newContent, "utf8");
1416
1869
  }
1417
1870
  return true;
1418
1871
  }
@@ -1437,7 +1890,7 @@ ${MARKER_END}
1437
1890
  ${content}
1438
1891
  ${afterMarker}`;
1439
1892
  if (!opts.dryRun) {
1440
- await writeFile3(destPath, updated, "utf8");
1893
+ await writeFile4(destPath, updated, "utf8");
1441
1894
  }
1442
1895
  return true;
1443
1896
  }
@@ -1447,7 +1900,7 @@ ${content}
1447
1900
  ${MARKER_END}
1448
1901
  `;
1449
1902
  if (!opts.dryRun) {
1450
- await writeFile3(destPath, appended, "utf8");
1903
+ await writeFile4(destPath, appended, "utf8");
1451
1904
  }
1452
1905
  return true;
1453
1906
  }
@@ -1455,7 +1908,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1455
1908
  const destPath = path3.resolve(targetDir, relativePath);
1456
1909
  let existing;
1457
1910
  try {
1458
- existing = await readFile4(destPath, "utf8");
1911
+ existing = await readFile5(destPath, "utf8");
1459
1912
  } catch {
1460
1913
  return false;
1461
1914
  }
@@ -1467,9 +1920,9 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1467
1920
  const stripped = (before + after).replace(/\n{3,}/g, "\n\n").trim();
1468
1921
  if (!opts.dryRun) {
1469
1922
  if (stripped.length === 0) {
1470
- await rm(destPath, { force: true });
1923
+ await rm2(destPath, { force: true });
1471
1924
  } else {
1472
- await writeFile3(destPath, stripped + "\n", "utf8");
1925
+ await writeFile4(destPath, stripped + "\n", "utf8");
1473
1926
  }
1474
1927
  }
1475
1928
  return true;
@@ -1590,7 +2043,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1590
2043
  const skipped = [];
1591
2044
  let hasUnresolvedRoles = false;
1592
2045
  const projectDefs = projectAgentsDir(targetDir);
1593
- const bundledDefs = join2(assetsDir, "agents");
2046
+ const bundledDefs = join3(assetsDir, "agents");
1594
2047
  const resolved = [];
1595
2048
  for (const entry of roster.roster) {
1596
2049
  let def;
@@ -1614,27 +2067,27 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1614
2067
  if (roster.openclaw.enabled && options.openclaw !== false) {
1615
2068
  const openclawRoot = roster.openclaw.workspace_root.replace(
1616
2069
  /^~/,
1617
- homedir()
2070
+ homedir2()
1618
2071
  );
1619
2072
  for (const { entry, def, displayName } of resolved) {
1620
2073
  if (!def.clients.openclaw) continue;
1621
2074
  const workspaceName = entry.id === "main" ? "workspace" : `workspace-${entry.id}`;
1622
- const workspaceDir = join2(openclawRoot, workspaceName);
2075
+ const workspaceDir = join3(openclawRoot, workspaceName);
1623
2076
  try {
1624
2077
  if (!options.dryRun) {
1625
- await mkdir2(workspaceDir, { recursive: true });
1626
- await writeFile4(
1627
- join2(workspaceDir, "IDENTITY.md"),
2078
+ await mkdir3(workspaceDir, { recursive: true });
2079
+ await writeFile5(
2080
+ join3(workspaceDir, "IDENTITY.md"),
1628
2081
  renderIdentity(def, displayName),
1629
2082
  "utf8"
1630
2083
  );
1631
- await writeFile4(
1632
- join2(workspaceDir, "SOUL.md"),
2084
+ await writeFile5(
2085
+ join3(workspaceDir, "SOUL.md"),
1633
2086
  renderSoul(def, displayName),
1634
2087
  "utf8"
1635
2088
  );
1636
- await writeFile4(
1637
- join2(workspaceDir, "TOOLS.md"),
2089
+ await writeFile5(
2090
+ join3(workspaceDir, "TOOLS.md"),
1638
2091
  renderTools(def, displayName),
1639
2092
  "utf8"
1640
2093
  );
@@ -1659,11 +2112,11 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1659
2112
  }
1660
2113
  }))
1661
2114
  };
1662
- const generatedDir = join2(targetDir, ".arcane", "generated");
2115
+ const generatedDir = join3(targetDir, ".arcane", "generated");
1663
2116
  if (!options.dryRun) {
1664
- await mkdir2(generatedDir, { recursive: true });
1665
- await writeFile4(
1666
- join2(generatedDir, "openclaw-roster.json"),
2117
+ await mkdir3(generatedDir, { recursive: true });
2118
+ await writeFile5(
2119
+ join3(generatedDir, "openclaw-roster.json"),
1667
2120
  JSON.stringify(patch, null, 2) + "\n",
1668
2121
  "utf8"
1669
2122
  );
@@ -1675,17 +2128,17 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1675
2128
  synced.push("openclaw-roster.json");
1676
2129
  }
1677
2130
  if (options.copilot !== false) {
1678
- const agentsDir = join2(targetDir, ".github", "agents");
2131
+ const agentsDir = join3(targetDir, ".github", "agents");
1679
2132
  if (!options.dryRun) {
1680
- await mkdir2(agentsDir, { recursive: true });
2133
+ await mkdir3(agentsDir, { recursive: true });
1681
2134
  }
1682
2135
  for (const { def, displayName } of resolved) {
1683
2136
  if (!def.clients.copilot?.agent_file) continue;
1684
2137
  const slug = displayName.toLowerCase().replace(/\s+/g, "-");
1685
2138
  const fileName = `${slug}.agent.md`;
1686
2139
  if (!options.dryRun) {
1687
- await writeFile4(
1688
- join2(agentsDir, fileName),
2140
+ await writeFile5(
2141
+ join3(agentsDir, fileName),
1689
2142
  renderCopilotAgent(def, displayName),
1690
2143
  "utf8"
1691
2144
  );
@@ -1826,11 +2279,11 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1826
2279
  },
1827
2280
  roster: rosterEntries
1828
2281
  };
1829
- const arcaneDir = join3(targetDir, ".arcane", "agents");
2282
+ const arcaneDir = join4(targetDir, ".arcane", "agents");
1830
2283
  if (!options.dryRun) {
1831
- await mkdir3(arcaneDir, { recursive: true });
1832
- await writeFile5(
1833
- join3(targetDir, ".arcane", "agents.yaml"),
2284
+ await mkdir4(arcaneDir, { recursive: true });
2285
+ await writeFile6(
2286
+ join4(targetDir, ".arcane", "agents.yaml"),
1834
2287
  stringify(roster),
1835
2288
  "utf8"
1836
2289
  );
@@ -1838,8 +2291,8 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1838
2291
  console.log(" [dry-run] Would write: .arcane/agents.yaml");
1839
2292
  }
1840
2293
  for (const roleId of selectedRoleIds) {
1841
- const src = join3(assetsDir, "agents", `${roleId}.yaml`);
1842
- const dest = join3(arcaneDir, `${roleId}.yaml`);
2294
+ const src = join4(assetsDir, "agents", `${roleId}.yaml`);
2295
+ const dest = join4(arcaneDir, `${roleId}.yaml`);
1843
2296
  if (!options.dryRun) {
1844
2297
  try {
1845
2298
  await copyFile2(src, dest);
@@ -1973,9 +2426,9 @@ async function runAgentsList(targetDir) {
1973
2426
  }
1974
2427
 
1975
2428
  // src/modules/push-safety.ts
1976
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile6, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
2429
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm3, readdir as readdir4 } from "fs/promises";
1977
2430
  import { constants } from "fs";
1978
- import { join as join4, resolve, dirname, basename } from "path";
2431
+ import { join as join5, resolve as resolve2, dirname as dirname2, basename } from "path";
1979
2432
 
1980
2433
  // src/modules/git.ts
1981
2434
  import { execFile } from "child_process";
@@ -2072,7 +2525,7 @@ function buildGitEnv() {
2072
2525
  function runGit(cwd, args, options = {}) {
2073
2526
  const commandClass = options.commandClass ?? classifyGitCommand(args);
2074
2527
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
2075
- return new Promise((resolve4, reject) => {
2528
+ return new Promise((resolve5, reject) => {
2076
2529
  const child = execFile(
2077
2530
  "git",
2078
2531
  args,
@@ -2106,7 +2559,7 @@ function runGit(cwd, args, options = {}) {
2106
2559
  reject(error);
2107
2560
  return;
2108
2561
  }
2109
- resolve4({ stdout, stderr });
2562
+ resolve5({ stdout, stderr });
2110
2563
  }
2111
2564
  );
2112
2565
  child.stdin?.end();
@@ -2224,7 +2677,7 @@ function isArcaneHooksPath(root, value) {
2224
2677
  const windows = process.platform === "win32";
2225
2678
  const normalize = (p) => {
2226
2679
  const separated = windows ? p.replace(/\\/g, "/") : p;
2227
- const absolute = resolve(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2680
+ const absolute = resolve2(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2228
2681
  return windows ? absolute.toLowerCase() : absolute;
2229
2682
  };
2230
2683
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
@@ -2272,7 +2725,7 @@ async function repositoryRoot(cwd) {
2272
2725
  ]);
2273
2726
  const commonDir = stdout.trim();
2274
2727
  if (commonDir !== "") {
2275
- const parent = dirname(commonDir);
2728
+ const parent = dirname2(commonDir);
2276
2729
  if (basename(commonDir) === ".git" && parent !== "") return parent;
2277
2730
  }
2278
2731
  } catch {
@@ -2286,10 +2739,10 @@ async function repositoryRoot(cwd) {
2286
2739
  return cwd;
2287
2740
  }
2288
2741
  async function hookFilePath(cwd, hookName = "pre-push") {
2289
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2742
+ return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2290
2743
  }
2291
2744
  async function hooksPathValue(cwd) {
2292
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2745
+ return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2293
2746
  }
2294
2747
  async function installHook(cwd, hookName, body) {
2295
2748
  const root = await repositoryRoot(cwd);
@@ -2308,12 +2761,12 @@ async function installHook(cwd, hookName, body) {
2308
2761
  return { status: "refused-default-hooks", hooks: displaced };
2309
2762
  }
2310
2763
  }
2311
- const hookPath = join4(root, ARCANE_HOOKS_DIR, hookName);
2764
+ const hookPath = join5(root, ARCANE_HOOKS_DIR, hookName);
2312
2765
  if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2313
2766
  return { status: "already-ours" };
2314
2767
  }
2315
- await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2316
- await writeFile6(hookPath, body, "utf-8");
2768
+ await mkdir5(join5(root, ARCANE_HOOKS_DIR), { recursive: true });
2769
+ await writeFile7(hookPath, body, "utf-8");
2317
2770
  await chmod(hookPath, 493);
2318
2771
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2319
2772
  return { status: "installed", path: hookPath };
@@ -2357,12 +2810,12 @@ async function defaultHooks(cwd) {
2357
2810
  let hooksDir;
2358
2811
  try {
2359
2812
  const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
2360
- hooksDir = join4(resolve(cwd, stdout.trim()), "hooks");
2813
+ hooksDir = join5(resolve2(cwd, stdout.trim()), "hooks");
2361
2814
  } catch {
2362
2815
  return [];
2363
2816
  }
2364
2817
  try {
2365
- return (await readdir3(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2818
+ return (await readdir4(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2366
2819
  } catch {
2367
2820
  return [];
2368
2821
  }
@@ -2371,7 +2824,7 @@ async function hookFileMatches(cwd, hookName, body) {
2371
2824
  const hookPath = await hookFilePath(cwd, hookName);
2372
2825
  if (!await fileExists(hookPath)) return false;
2373
2826
  try {
2374
- if (await readFile5(hookPath, "utf-8") !== body) return false;
2827
+ if (await readFile6(hookPath, "utf-8") !== body) return false;
2375
2828
  } catch {
2376
2829
  return false;
2377
2830
  }
@@ -2390,13 +2843,13 @@ async function removeHook(cwd, hookName) {
2390
2843
  if (!isArcaneHooksPath(root, existing.value)) return;
2391
2844
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2392
2845
  try {
2393
- await rm2(await hookFilePath(cwd, hookName), { force: true });
2846
+ await rm3(await hookFilePath(cwd, hookName), { force: true });
2394
2847
  } catch {
2395
2848
  }
2396
- const hooksDir = join4(root, ARCANE_HOOKS_DIR);
2849
+ const hooksDir = join5(root, ARCANE_HOOKS_DIR);
2397
2850
  let remaining = [];
2398
2851
  try {
2399
- remaining = await readdir3(hooksDir);
2852
+ remaining = await readdir4(hooksDir);
2400
2853
  } catch {
2401
2854
  remaining = [];
2402
2855
  }
@@ -2682,13 +3135,9 @@ function isSpellComponent(name) {
2682
3135
  function categorizeComponents(components) {
2683
3136
  const groups = [];
2684
3137
  const spellFiles = components.filter((c) => isSpellComponent(c.name)).flatMap((c) => c.files);
2685
- const promptCount = spellFiles.filter((f) => f.startsWith(".github/prompts/")).length;
2686
- if (promptCount > 0) {
2687
- groups.push({ icon: "\u2728", label: "Copilot Spells", count: promptCount });
2688
- }
2689
- const claudeCount = spellFiles.filter((f) => f.startsWith(".claude/commands/")).length;
2690
- if (claudeCount > 0) {
2691
- groups.push({ icon: "\u26A1", label: "Claude Spells", count: claudeCount });
3138
+ const spellCount = spellFiles.filter((f) => f.startsWith(".arcane/spells/")).length;
3139
+ if (spellCount > 0) {
3140
+ groups.push({ icon: "\u2728", label: "Spells (Copilot, Claude Code, Codex)", count: spellCount });
2692
3141
  }
2693
3142
  const governance = components.filter(
2694
3143
  (c) => !isSpellComponent(c.name) && c.name !== "venture-template" && c.name !== "agent-definitions" && c.files.some((f) => f.startsWith(".arcane/governance/"))
@@ -2728,6 +3177,10 @@ function buildProfileDescription(components, profile) {
2728
3177
  return "\n" + parts.join("\n");
2729
3178
  }
2730
3179
  async function runInit(options, targetDir, assetsDir, packageVersion) {
3180
+ if (options.user) {
3181
+ await runInitUser(options, targetDir, assetsDir, packageVersion);
3182
+ return;
3183
+ }
2731
3184
  if (options.profile && !VALID_PROFILES.includes(options.profile)) {
2732
3185
  throw new Error(
2733
3186
  `Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
@@ -2832,7 +3285,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2832
3285
  const installedFiles = [];
2833
3286
  const fileHashes = {};
2834
3287
  for (const file of component.files) {
2835
- const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
3288
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
2836
3289
  if (options.dryRun) {
2837
3290
  printDryRun(`Would copy: ${file}`);
2838
3291
  } else {
@@ -2849,7 +3302,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2849
3302
  fileCount++;
2850
3303
  }
2851
3304
  for (const dir of component.directories ?? []) {
2852
- const srcDirPath = join5(assetsDir, dir);
3305
+ const srcDirPath = join6(assetsDir, dir);
2853
3306
  if (options.dryRun) {
2854
3307
  printDryRun(`Would copy directory: ${dir}/`);
2855
3308
  } else {
@@ -2878,7 +3331,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2878
3331
  if (options.dryRun) {
2879
3332
  if (hasAgents) {
2880
3333
  const agentFiles = await import("fs").then(
2881
- (fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
3334
+ (fsModule) => fsModule.promises.readdir(join6(assetsDir, "agents"))
2882
3335
  );
2883
3336
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
2884
3337
  printDryRun("Would run agent setup (interactive):");
@@ -3120,9 +3573,110 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3120
3573
  );
3121
3574
  console.log();
3122
3575
  }
3576
+ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3577
+ if (options.profile) {
3578
+ throw new Error(
3579
+ '"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
3580
+ );
3581
+ }
3582
+ try {
3583
+ await readManifest(storeRoot);
3584
+ console.log(chalk2.hex("#a855f7")(`
3585
+ \u2726 Arcane v${packageVersion}
3586
+ `));
3587
+ console.log(
3588
+ `Already initialized (user tier at ${storeRoot}). Run "spell update --user" to update it.`
3589
+ );
3590
+ return;
3591
+ } catch (err) {
3592
+ if (!(err instanceof ManifestNotFoundError)) throw err;
3593
+ }
3594
+ console.log(chalk2.hex("#a855f7")(`
3595
+ \u2726 Arcane v${packageVersion}
3596
+ `));
3597
+ const homeDir = dirname3(storeRoot);
3598
+ const components = USER_TIER_COMPONENTS.map(
3599
+ (name) => componentForScope(getComponent(name), "user")
3600
+ );
3601
+ const spellIds = components.flatMap((c) => c.files).map((file) => spellIdFromStorePath(file)).filter((id) => id !== void 0);
3602
+ printSection(`\u{1F4E6} User tier \u2014 ${storeRoot}`);
3603
+ console.log();
3604
+ console.log(
3605
+ ` \u2728 ${spellIds.length} Spells \u2192 ${storeRoot}/spells/ \xB7 \u{1F517} ${spellIds.length * 2} client files \u2192 ~/.agents/skills (Codex, Copilot), ~/.claude/commands (Claude Code)`
3606
+ );
3607
+ console.log();
3608
+ if (options.dryRun) {
3609
+ for (const component of components) {
3610
+ for (const file of component.files) printDryRun(`Would copy: ${file}`);
3611
+ }
3612
+ for (const id of spellIds) {
3613
+ for (const client of ["codex", "claude"]) {
3614
+ const relativePath = USER_FANOUT_PATHS[client](id);
3615
+ if (await fileExists(join6(homeDir, relativePath))) {
3616
+ printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
3617
+ } else {
3618
+ printDryRun(`Would write: ~/${relativePath}`);
3619
+ }
3620
+ }
3621
+ }
3622
+ printDryRun(
3623
+ `Would initialize the user tier \u2014 ${spellIds.length} spells, ${spellIds.length * 2} client files`
3624
+ );
3625
+ return;
3626
+ }
3627
+ const installedComponents = [];
3628
+ let fileCount = 0;
3629
+ for (const component of components) {
3630
+ const installedFiles = [];
3631
+ const fileHashes = {};
3632
+ for (const file of component.files) {
3633
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3634
+ fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
3635
+ installedFiles.push(file);
3636
+ fileCount++;
3637
+ }
3638
+ installedComponents.push({
3639
+ name: component.name,
3640
+ files: installedFiles,
3641
+ installedVersion: packageVersion,
3642
+ fileHashes
3643
+ });
3644
+ }
3645
+ const fanout = await syncUserTierFanout({ homeDir, storeRoot, spellIds, fallbackDir: assetsDir });
3646
+ const manifest = {
3647
+ version: packageVersion,
3648
+ // The tier is un-profiled; "full" records the profile its component set
3649
+ // is drawn from, and every command reads `scope` before `profile`.
3650
+ profile: "full",
3651
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
3652
+ components: installedComponents,
3653
+ scope: "user",
3654
+ fanout: fanout.record
3655
+ };
3656
+ await writeManifest(storeRoot, manifest);
3657
+ printStep(`\u2728 ${fileCount} Spells installed to ${storeRoot}/spells/`);
3658
+ for (const line of describeFanoutOutcomes(fanout.outcomes)) {
3659
+ if (line.startsWith("!")) printWarning(line.slice(2));
3660
+ else printStep(`\u{1F517} ${line}`);
3661
+ }
3662
+ printSuccess(
3663
+ `Initialized the user tier \u2014 ${fileCount} spells, ${Object.keys(fanout.record).length} client files recorded`
3664
+ );
3665
+ console.log();
3666
+ printSection("\u{1F9ED} Clients");
3667
+ console.log();
3668
+ for (const line of VSCODE_USER_TIER_NOTE) printInfo(line);
3669
+ printInfo(CLAUDE_PRECEDENCE_NOTE);
3670
+ console.log();
3671
+ printSection("\u{1F680} Next steps");
3672
+ console.log();
3673
+ printNextStep(1, "Reload VS Code, and start a fresh Codex or Claude Code session, so the pickers see the new files");
3674
+ printNextStep(2, "`spell status --user` shows the tier; `spell update --user` refreshes it after upgrading the CLI");
3675
+ console.log();
3676
+ }
3123
3677
 
3124
3678
  // src/commands/add.ts
3125
- import { join as join6 } from "path";
3679
+ import { join as join7 } from "path";
3126
3680
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3127
3681
  let manifest;
3128
3682
  try {
@@ -3158,7 +3712,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3158
3712
  const installedFiles = [];
3159
3713
  const fileHashes = {};
3160
3714
  for (const file of component.files) {
3161
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3715
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3162
3716
  if (options.dryRun) {
3163
3717
  console.log(` [dry-run] Would copy: ${file}`);
3164
3718
  } else {
@@ -3168,7 +3722,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3168
3722
  fileCount++;
3169
3723
  }
3170
3724
  for (const dir of component.directories ?? []) {
3171
- const srcDirPath = join6(assetsDir, dir);
3725
+ const srcDirPath = join7(assetsDir, dir);
3172
3726
  if (options.dryRun) {
3173
3727
  console.log(` [dry-run] Would copy directory: ${dir}/`);
3174
3728
  } else {
@@ -3202,13 +3756,13 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3202
3756
  }
3203
3757
 
3204
3758
  // src/commands/update.ts
3205
- import { join as join8 } from "path";
3206
- import { readFile as readFile6, writeFile as writeFile8, rm as rm3 } from "fs/promises";
3759
+ import { dirname as dirname4, join as join9 } from "path";
3760
+ import { readFile as readFile7, writeFile as writeFile9, rm as rm4 } from "fs/promises";
3207
3761
 
3208
3762
  // src/modules/hub.ts
3209
3763
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
3210
- import { readdir as readdir4, writeFile as writeFile7 } from "fs/promises";
3211
- import { join as join7 } from "path";
3764
+ import { readdir as readdir5, writeFile as writeFile8 } from "fs/promises";
3765
+ import { join as join8 } from "path";
3212
3766
  var MANIFEST_RETROFITS = [
3213
3767
  {
3214
3768
  field: "role",
@@ -3347,12 +3901,12 @@ async function runManifestRetrofits(manifest) {
3347
3901
  return patch;
3348
3902
  }
3349
3903
  async function offerRegistryScaffold(targetDir, businessRoot) {
3350
- const registryPath = join7(targetDir, businessRoot, "registry.json");
3904
+ const registryPath = join8(targetDir, businessRoot, "registry.json");
3351
3905
  if (await fileExists(registryPath)) return;
3352
- const businessRootPath = join7(targetDir, businessRoot);
3906
+ const businessRootPath = join8(targetDir, businessRoot);
3353
3907
  let ventureDirs;
3354
3908
  try {
3355
- const entries = await readdir4(businessRootPath, { withFileTypes: true });
3909
+ const entries = await readdir5(businessRootPath, { withFileTypes: true });
3356
3910
  ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
3357
3911
  } catch {
3358
3912
  return;
@@ -3387,7 +3941,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
3387
3941
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3388
3942
  ventures
3389
3943
  };
3390
- await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3944
+ await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3391
3945
  console.log(
3392
3946
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
3393
3947
  );
@@ -3556,7 +4110,7 @@ function migrateLegacyComponents(components) {
3556
4110
  return out;
3557
4111
  }
3558
4112
  async function resolveOrphan(targetDir, file, recordedHash, prune) {
3559
- const filePath = join8(targetDir, file);
4113
+ const filePath = join9(targetDir, file);
3560
4114
  if (!await fileExists(filePath)) return "not-found";
3561
4115
  if (!prune) return "reported";
3562
4116
  if (recordedHash === void 0) {
@@ -3568,10 +4122,27 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
3568
4122
  console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
3569
4123
  return "reported";
3570
4124
  }
3571
- await rm3(filePath, { force: true });
4125
+ await rm4(filePath, { force: true });
3572
4126
  console.log(` Pruned orphaned file: ${file}`);
3573
4127
  return "pruned";
3574
4128
  }
4129
+ async function findMissingTrackedFiles(targetDir, components, scope = "repo") {
4130
+ const missing = [];
4131
+ for (const installed of components) {
4132
+ let component;
4133
+ try {
4134
+ component = componentForScope(getComponent(installed.name), scope);
4135
+ } catch {
4136
+ continue;
4137
+ }
4138
+ if (component.initOnly || component.skipExisting) continue;
4139
+ for (const file of installed.files) {
4140
+ if (!component.files.includes(file)) continue;
4141
+ if (!await fileExists(join9(targetDir, file))) missing.push(file);
4142
+ }
4143
+ }
4144
+ return missing;
4145
+ }
3575
4146
  async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3576
4147
  let manifest;
3577
4148
  try {
@@ -3579,44 +4150,76 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3579
4150
  } catch (err) {
3580
4151
  if (err instanceof ManifestNotFoundError) {
3581
4152
  console.error(
3582
- 'Not initialized. Run "spell init" first before updating.'
4153
+ `Not initialized. Run "spell init${options.user ? " --user" : ""}" first before updating.`
3583
4154
  );
3584
4155
  process.exit(1);
3585
4156
  return;
3586
4157
  }
3587
4158
  throw err;
3588
4159
  }
3589
- console.warn(
3590
- `WARNING: Arcane v${packageVersion} update safety notice: commit your work before updating.`
3591
- );
3592
- console.warn(
3593
- "Updates can replace managed files. A clean committed baseline is required for recovery."
3594
- );
3595
- const gitState = await inspectGitRepository(targetDir);
3596
- if (gitState.status === "not-repository") {
3597
- console.error(
3598
- "Update refused: this directory is not a Git repository or Git is unavailable."
3599
- );
4160
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4161
+ if (resolvedScope.misplacedUserManifest) {
4162
+ console.error(misplacedUserManifestMessage(targetDir));
3600
4163
  process.exit(1);
3601
4164
  return;
3602
4165
  }
3603
- if (gitState.status === "no-commits") {
3604
- console.error(
3605
- "Update refused: this repository has no commits. Commit the current baseline before updating."
4166
+ const scope = resolvedScope.scope;
4167
+ const homeDir = dirname4(targetDir);
4168
+ if (scope === "repo") {
4169
+ console.warn(
4170
+ `WARNING: Arcane v${packageVersion} update safety notice: commit your work before updating.`
3606
4171
  );
3607
- process.exit(1);
3608
- return;
3609
- }
3610
- if (gitState.uncommittedChanges > 0) {
3611
- console.error(
3612
- `Update refused: this repository has ${gitState.uncommittedChanges} uncommitted change${gitState.uncommittedChanges === 1 ? "" : "s"}. Commit or otherwise clean the working tree before updating.`
4172
+ console.warn(
4173
+ "Updates can replace managed files. A clean committed baseline is required for recovery."
3613
4174
  );
3614
- process.exit(1);
3615
- return;
4175
+ const gitState = await inspectGitRepository(targetDir);
4176
+ if (gitState.status === "not-repository") {
4177
+ console.error(
4178
+ "Update refused: this directory is not a Git repository or Git is unavailable."
4179
+ );
4180
+ process.exit(1);
4181
+ return;
4182
+ }
4183
+ if (gitState.status === "no-commits") {
4184
+ console.error(
4185
+ "Update refused: this repository has no commits. Commit the current baseline before updating."
4186
+ );
4187
+ process.exit(1);
4188
+ return;
4189
+ }
4190
+ if (gitState.uncommittedChanges > 0) {
4191
+ console.error(
4192
+ `Update refused: this repository has ${gitState.uncommittedChanges} uncommitted change${gitState.uncommittedChanges === 1 ? "" : "s"}. Commit or otherwise clean the working tree before updating.`
4193
+ );
4194
+ process.exit(1);
4195
+ return;
4196
+ }
3616
4197
  }
4198
+ let sameVersionRestore = false;
3617
4199
  if (manifest.version === packageVersion && manifest.components.length > 0) {
3618
- console.log("Already up to date.");
3619
- return;
4200
+ const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
4201
+ if (missing.length === 0) {
4202
+ console.log("Already up to date.");
4203
+ if (scope === "user") {
4204
+ const fanout = await syncUserTierFanout({
4205
+ homeDir,
4206
+ storeRoot: targetDir,
4207
+ spellIds: spellIdsInStore(manifest.components),
4208
+ previous: manifest.fanout,
4209
+ dryRun: options.dryRun,
4210
+ fallbackDir: assetsDir
4211
+ });
4212
+ for (const line of describeFanoutOutcomes(fanout.outcomes, options.dryRun)) console.log(` ${line}`);
4213
+ if (!options.dryRun && JSON.stringify(fanout.record) !== JSON.stringify(manifest.fanout ?? {})) {
4214
+ await writeManifest(targetDir, { ...manifest, fanout: fanout.record });
4215
+ }
4216
+ }
4217
+ return;
4218
+ }
4219
+ 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
+ );
3620
4223
  }
3621
4224
  if (manifest.components.length === 0) {
3622
4225
  console.log("No components installed. Nothing to update.");
@@ -3625,12 +4228,13 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3625
4228
  let fileCount = 0;
3626
4229
  const updatedComponents = [];
3627
4230
  const conflictedFiles = [];
4231
+ const customizedShims = [];
3628
4232
  const orphanReport = [];
3629
4233
  const componentsToUpdate = migrateLegacyComponents(manifest.components);
3630
4234
  for (const installed of componentsToUpdate) {
3631
4235
  let component;
3632
4236
  try {
3633
- component = getComponent(installed.name);
4237
+ component = componentForScope(getComponent(installed.name), scope);
3634
4238
  } catch (err) {
3635
4239
  if (err instanceof ComponentNotFoundError) {
3636
4240
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
@@ -3654,52 +4258,72 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3654
4258
  const updatedFiles = [];
3655
4259
  const fileHashes = {};
3656
4260
  for (const file of component.files) {
3657
- const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3658
- const targetExists = await fileExists(join8(targetDir, file));
4261
+ const srcPath = join9(assetsDir, component.sourceOverrides?.[file] ?? file);
4262
+ const targetExists = await fileExists(join9(targetDir, file));
3659
4263
  const preserveExisting = Boolean(component.skipExisting) && targetExists;
4264
+ const recordedHash = installed.fileHashes?.[file];
3660
4265
  if (component.initOnly && !targetExists) {
3661
4266
  console.log(
3662
4267
  ` ! Missing: ${file} \u2014 not added automatically, because doing so would change how Git treats existing files. Run "spell add ${component.name}" if you want it.`
3663
4268
  );
3664
4269
  continue;
3665
4270
  }
4271
+ if (sameVersionRestore) {
4272
+ const tracked = installed.files.includes(file);
4273
+ const restorable = tracked && !targetExists && !component.skipExisting;
4274
+ if (!restorable) {
4275
+ if (tracked) {
4276
+ updatedFiles.push(file);
4277
+ if (recordedHash !== void 0) fileHashes[file] = recordedHash;
4278
+ }
4279
+ continue;
4280
+ }
4281
+ }
4282
+ const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && await hashFile(join9(targetDir, file)) !== recordedHash;
3666
4283
  if (preserveExisting) {
3667
4284
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
4285
+ } else if (editedByOperator && isClientShimPath(file)) {
4286
+ customizedShims.push(file);
4287
+ fileHashes[file] = recordedHash;
4288
+ console.log(
4289
+ ` ${options.dryRun ? "[dry-run] Would keep" : "Kept"} customized (not merged): ${file}`
4290
+ );
3668
4291
  } else if (options.dryRun) {
3669
- console.log(` [dry-run] Would update: ${file}`);
4292
+ console.log(` [dry-run] Would ${sameVersionRestore ? "restore missing" : "update"}: ${file}`);
3670
4293
  fileCount++;
3671
4294
  } else {
3672
- const recordedHash = installed.fileHashes?.[file];
3673
4295
  let handled = false;
3674
- if (recordedHash !== void 0 && targetExists) {
3675
- const currentHash = await hashFile(join8(targetDir, file));
3676
- if (currentHash !== recordedHash) {
3677
- const oldVendorContent = await fetchPublishedFile(installed.installedVersion, file);
3678
- if (oldVendorContent === void 0) {
3679
- console.log(
3680
- ` ! Could not fetch the previously published version of ${file} to merge your edits \u2014 left your version untouched. Update it manually if you want the latest.`
3681
- );
3682
- handled = true;
4296
+ if (editedByOperator) {
4297
+ const oldVendorContent = await fetchPublishedFile(
4298
+ installed.installedVersion,
4299
+ component.sourceOverrides?.[file] ?? file
4300
+ );
4301
+ if (oldVendorContent === void 0) {
4302
+ console.log(
4303
+ ` ! Could not fetch the previously published version of ${file} to merge your edits \u2014 left your version untouched. Update it manually if you want the latest.`
4304
+ );
4305
+ fileHashes[file] = recordedHash;
4306
+ handled = true;
4307
+ } else {
4308
+ const [currentContent, newContent] = await Promise.all([
4309
+ readFile7(join9(targetDir, file), "utf-8"),
4310
+ readFile7(srcPath, "utf-8")
4311
+ ]);
4312
+ const result = merge3(oldVendorContent, currentContent, newContent);
4313
+ await writeFile9(join9(targetDir, file), result.content, "utf-8");
4314
+ fileHashes[file] = await hashFile(srcPath);
4315
+ if (result.hasConflict) {
4316
+ conflictedFiles.push(file);
4317
+ console.log(` \u26A0 Merge conflict in ${file} \u2014 resolve the <<<<<<< markers before committing.`);
3683
4318
  } else {
3684
- const [currentContent, newContent] = await Promise.all([
3685
- readFile6(join8(targetDir, file), "utf-8"),
3686
- readFile6(srcPath, "utf-8")
3687
- ]);
3688
- const result = merge3(oldVendorContent, currentContent, newContent);
3689
- await writeFile8(join8(targetDir, file), result.content, "utf-8");
3690
- fileHashes[file] = await hashFile(join8(targetDir, file));
3691
- if (result.hasConflict) {
3692
- conflictedFiles.push(file);
3693
- console.log(` \u26A0 Merge conflict in ${file} \u2014 resolve the <<<<<<< markers before committing.`);
3694
- } else {
3695
- console.log(` Merged your edits into: ${file}`);
3696
- }
3697
- handled = true;
4319
+ console.log(` Merged your edits into: ${file}`);
3698
4320
  }
4321
+ handled = true;
3699
4322
  }
3700
4323
  }
3701
4324
  if (!handled) {
3702
4325
  fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: true });
4326
+ if (sameVersionRestore) console.log(` Restored missing: ${file}`);
3703
4327
  }
3704
4328
  fileCount++;
3705
4329
  }
@@ -3708,7 +4332,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3708
4332
  }
3709
4333
  }
3710
4334
  for (const dir of component.directories ?? []) {
3711
- const srcDirPath = join8(assetsDir, dir);
4335
+ const srcDirPath = join9(assetsDir, dir);
3712
4336
  if (options.dryRun) {
3713
4337
  console.log(` [dry-run] Would update directory: ${dir}/`);
3714
4338
  } else {
@@ -3752,12 +4376,38 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3752
4376
  console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
3753
4377
  }
3754
4378
  }
4379
+ if (customizedShims.length > 0) {
4380
+ const verb = options.dryRun ? "[dry-run] Would keep" : "Kept";
4381
+ console.log(
4382
+ `
4383
+ ! ${verb} ${customizedShims.length} customized client file(s) untouched (ARC-045): an edited Copilot prompt, Claude command or Codex skill is never merged into the new generated shim, because a shim carries no prose of its own to merge into.
4384
+ ` + customizedShims.map((f) => ` ${f}`).join("\n") + `
4385
+ Each keeps working in its client as-is. To converge: move your edits into the canonical spell (.arcane/spells/<id>.md \u2014 later updates merge edits there), delete the customized file, commit, and run \`spell update\` again \u2014 it restores the generated shim at the current version.`
4386
+ );
4387
+ }
4388
+ let fanoutRecord;
4389
+ if (scope === "user") {
4390
+ const fanout = await syncUserTierFanout({
4391
+ homeDir,
4392
+ storeRoot: targetDir,
4393
+ spellIds: spellIdsInStore(updatedComponents),
4394
+ previous: manifest.fanout,
4395
+ dryRun: options.dryRun,
4396
+ // A dry run has restored nothing yet, so a missing store spell renders
4397
+ // from the vendor asset the real run would write (review finding F1).
4398
+ fallbackDir: assetsDir
4399
+ });
4400
+ const lines = describeFanoutOutcomes(fanout.outcomes, options.dryRun);
4401
+ if (lines.length > 0) console.log();
4402
+ for (const line of lines) console.log(` ${line}`);
4403
+ fanoutRecord = fanout.record;
4404
+ }
3755
4405
  if (options.dryRun) {
3756
4406
  console.log(
3757
4407
  `
3758
4408
  [dry-run] Would update ${fileCount} files.`
3759
4409
  );
3760
- const applicableRetrofits = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
4410
+ const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest)) : [];
3761
4411
  if (applicableRetrofits.length > 0) {
3762
4412
  console.log(
3763
4413
  `[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
@@ -3765,9 +4415,9 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3765
4415
  }
3766
4416
  return;
3767
4417
  }
3768
- const interactive = Boolean(process.stdin.isTTY);
4418
+ const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
3769
4419
  const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
3770
- if (!interactive) {
4420
+ if (!interactive && scope === "repo") {
3771
4421
  const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
3772
4422
  if (pending.length > 0) {
3773
4423
  console.log(
@@ -3779,7 +4429,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3779
4429
  ...manifest,
3780
4430
  version: packageVersion,
3781
4431
  components: updatedComponents,
3782
- ...retrofitPatch
4432
+ ...retrofitPatch,
4433
+ ...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {}
3783
4434
  };
3784
4435
  await writeManifest(targetDir, updated);
3785
4436
  console.log(
@@ -3794,15 +4445,18 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3794
4445
  Search for "<<<<<<< yours" and resolve before committing.`
3795
4446
  );
3796
4447
  }
3797
- if (manifest.role !== "hub" && updated.role === "hub") {
4448
+ if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
3798
4449
  await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
3799
4450
  }
3800
4451
  }
3801
4452
 
4453
+ // src/commands/status.ts
4454
+ import { dirname as dirname6 } from "path";
4455
+
3802
4456
  // src/modules/version-check.ts
3803
- import { readFile as readFile7, writeFile as writeFile9, mkdir as mkdir5 } from "fs/promises";
3804
- import { dirname as dirname2, join as join9 } from "path";
3805
- import { homedir as homedir2 } from "os";
4457
+ import { readFile as readFile8, writeFile as writeFile10, mkdir as mkdir6 } from "fs/promises";
4458
+ import { dirname as dirname5, join as join10 } from "path";
4459
+ import { homedir as homedir3 } from "os";
3806
4460
  import { createRequire } from "module";
3807
4461
  var require2 = createRequire(import.meta.url);
3808
4462
  function getFeedUrl() {
@@ -3815,11 +4469,11 @@ function getFeedUrl() {
3815
4469
  }
3816
4470
  return url;
3817
4471
  }
3818
- var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
4472
+ var DEFAULT_CACHE_PATH = join10(homedir3(), ".arcane", "version-cache.json");
3819
4473
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3820
4474
  async function readCache(cachePath) {
3821
4475
  try {
3822
- const content = await readFile7(cachePath, "utf-8");
4476
+ const content = await readFile8(cachePath, "utf-8");
3823
4477
  const parsed = JSON.parse(content);
3824
4478
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
3825
4479
  return null;
@@ -3830,9 +4484,9 @@ async function readCache(cachePath) {
3830
4484
  }
3831
4485
  }
3832
4486
  async function writeCache(cachePath, latest) {
3833
- await mkdir5(dirname2(cachePath), { recursive: true });
4487
+ await mkdir6(dirname5(cachePath), { recursive: true });
3834
4488
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3835
- await writeFile9(cachePath, JSON.stringify(entry, null, 2), "utf-8");
4489
+ await writeFile10(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3836
4490
  }
3837
4491
  async function getLatestVersion(feedUrl) {
3838
4492
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -3911,20 +4565,27 @@ function generateVersionDriftDiagram(repoFilesVersion, installedCliVersion, npmL
3911
4565
  }
3912
4566
 
3913
4567
  // src/commands/status.ts
3914
- async function runStatus(targetDir, packageVersion) {
4568
+ async function runStatus(targetDir, packageVersion, options = {}) {
3915
4569
  let manifest;
3916
4570
  try {
3917
4571
  manifest = await readManifest(targetDir);
3918
4572
  } catch (err) {
3919
4573
  if (err instanceof ManifestNotFoundError) {
3920
4574
  console.error(
3921
- 'Not initialized. Run "spell init" first.'
4575
+ `Not initialized. Run "spell init${options.user ? " --user" : ""}" first.`
3922
4576
  );
3923
4577
  process.exit(1);
3924
4578
  return;
3925
4579
  }
3926
4580
  throw err;
3927
4581
  }
4582
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4583
+ if (resolvedScope.misplacedUserManifest) {
4584
+ console.error(misplacedUserManifestMessage(targetDir));
4585
+ process.exit(1);
4586
+ return;
4587
+ }
4588
+ const scope = resolvedScope.scope;
3928
4589
  if (manifest.components.length === 0) {
3929
4590
  console.log("No components installed.");
3930
4591
  return;
@@ -3962,6 +4623,30 @@ async function runStatus(targetDir, packageVersion) {
3962
4623
  );
3963
4624
  }
3964
4625
  console.log("");
4626
+ if (scope === "user") {
4627
+ console.log(` Scope: user \u2014 ${targetDir}`);
4628
+ const health = await inspectUserTierFanout(dirname6(targetDir), manifest.fanout);
4629
+ console.log(
4630
+ ` Client files: ${health.total} (${health.byClient.codex} ${FANOUT_CLIENT_LABELS.codex}, ${health.byClient.claude} ${FANOUT_CLIENT_LABELS.claude})`
4631
+ );
4632
+ if (health.missing.length > 0 || health.customized.length > 0) {
4633
+ const parts = [];
4634
+ if (health.missing.length > 0) parts.push(`${health.missing.length} missing`);
4635
+ if (health.customized.length > 0) parts.push(`${health.customized.length} customized`);
4636
+ console.log(
4637
+ ` ! ${parts.join(", ")} \u2014 \`spell update --user\` regenerates missing files; customized files are never overwritten.`
4638
+ );
4639
+ }
4640
+ } else {
4641
+ console.log(" Scope: repo");
4642
+ try {
4643
+ const store = userTierRoot();
4644
+ const userManifest = await readManifest(store);
4645
+ console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
4646
+ } catch {
4647
+ }
4648
+ }
4649
+ console.log("");
3965
4650
  const latestStr = versionResult.error ? "unable to check" : versionResult.latest ?? manifest.version;
3966
4651
  let footer = ` Installed: ${packageVersion} Latest: ${latestStr}`;
3967
4652
  if (versionResult.updateAvailable) {
@@ -3988,11 +4673,16 @@ async function runStatus(targetDir, packageVersion) {
3988
4673
  }
3989
4674
  }
3990
4675
  }
4676
+ if (scope === "user") {
4677
+ console.log("");
4678
+ for (const line of VSCODE_USER_TIER_NOTE) console.log(` ${line}`);
4679
+ console.log(` ${CLAUDE_PRECEDENCE_NOTE}`);
4680
+ }
3991
4681
  }
3992
4682
 
3993
4683
  // src/commands/uninstall.ts
3994
- import { rm as rm4 } from "fs/promises";
3995
- import { join as join10 } from "path";
4684
+ import { rm as rm5 } from "fs/promises";
4685
+ import { dirname as dirname7, join as join11 } from "path";
3996
4686
  import { confirm as confirm3 } from "@inquirer/prompts";
3997
4687
  async function runUninstall(options, targetDir) {
3998
4688
  let manifest;
@@ -4000,12 +4690,22 @@ async function runUninstall(options, targetDir) {
4000
4690
  manifest = await readManifest(targetDir);
4001
4691
  } catch (err) {
4002
4692
  if (err instanceof ManifestNotFoundError) {
4003
- console.error('Not initialized. Run "spell init" first.');
4693
+ console.error(`Not initialized. Run "spell init${options.user ? " --user" : ""}" first.`);
4004
4694
  process.exit(1);
4005
4695
  return;
4006
4696
  }
4007
4697
  throw err;
4008
4698
  }
4699
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4700
+ if (resolvedScope.misplacedUserManifest) {
4701
+ console.error(misplacedUserManifestMessage(targetDir));
4702
+ process.exit(1);
4703
+ return;
4704
+ }
4705
+ if (resolvedScope.scope === "user") {
4706
+ await uninstallUserTier(options, targetDir, manifest);
4707
+ return;
4708
+ }
4009
4709
  const policy = manifest.push_policy ?? "open";
4010
4710
  const controlsInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
4011
4711
  if ((policy === "blocked" || controlsInForce) && !options.dryRun) {
@@ -4055,24 +4755,24 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4055
4755
  let removed = 0;
4056
4756
  for (const component of manifest.components) {
4057
4757
  for (const file of component.files) {
4058
- const filePath = join10(targetDir, file);
4758
+ const filePath = join11(targetDir, file);
4059
4759
  try {
4060
- await rm4(filePath, { force: true });
4760
+ await rm5(filePath, { force: true });
4061
4761
  removed++;
4062
4762
  } catch {
4063
4763
  }
4064
4764
  }
4065
4765
  }
4066
- await rm4(join10(targetDir, ".arcane.json"), { force: true });
4766
+ await rm5(join11(targetDir, ".arcane.json"), { force: true });
4067
4767
  const agentDirs = [
4068
4768
  ".github/agents",
4069
4769
  ".arcane/agents",
4070
4770
  ".arcane/generated"
4071
4771
  ];
4072
4772
  for (const dir of agentDirs) {
4073
- await rm4(join10(targetDir, dir), { recursive: true, force: true });
4773
+ await rm5(join11(targetDir, dir), { recursive: true, force: true });
4074
4774
  }
4075
- await rm4(join10(targetDir, ".arcane/agents.yaml"), { force: true });
4775
+ await rm5(join11(targetDir, ".arcane/agents.yaml"), { force: true });
4076
4776
  const markerFiles = [
4077
4777
  "CLAUDE.md",
4078
4778
  "AGENTS.md",
@@ -4088,10 +4788,63 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4088
4788
  console.log(`
4089
4789
  \u2713 Uninstalled \u2014 ${removed} files removed.`);
4090
4790
  }
4791
+ async function uninstallUserTier(options, storeRoot, manifest) {
4792
+ const homeDir = dirname7(storeRoot);
4793
+ const storeFiles = manifest.components.flatMap((c) => c.files);
4794
+ const recordedCount = Object.keys(manifest.fanout ?? {}).length;
4795
+ if (options.dryRun) {
4796
+ for (const file of storeFiles) console.log(`[dry-run] Would remove: ${storeRoot}/${file}`);
4797
+ const preview = await syncUserTierFanout({
4798
+ homeDir,
4799
+ storeRoot,
4800
+ spellIds: [],
4801
+ previous: manifest.fanout,
4802
+ dryRun: true
4803
+ });
4804
+ for (const line of describeFanoutOutcomes(preview.outcomes, true)) console.log(line);
4805
+ console.log(`[dry-run] Would remove: ${storeRoot}/.arcane.json`);
4806
+ console.log(
4807
+ `
4808
+ [dry-run] ${storeFiles.length} spell(s), up to ${recordedCount} client file(s) + manifest would be removed from the user tier.`
4809
+ );
4810
+ return;
4811
+ }
4812
+ if (!options.yes) {
4813
+ const confirmed = await confirm3({
4814
+ message: `This will remove ${storeFiles.length} spells, ${recordedCount} client files and .arcane.json from the user tier at ${storeRoot}. Continue?`,
4815
+ default: false
4816
+ });
4817
+ if (!confirmed) {
4818
+ console.log("Uninstall cancelled.");
4819
+ return;
4820
+ }
4821
+ }
4822
+ const fanout = await syncUserTierFanout({
4823
+ homeDir,
4824
+ storeRoot,
4825
+ spellIds: [],
4826
+ previous: manifest.fanout
4827
+ });
4828
+ let removed = 0;
4829
+ for (const file of storeFiles) {
4830
+ await rm5(join11(storeRoot, file), { force: true });
4831
+ removed++;
4832
+ }
4833
+ await rm5(join11(storeRoot, ".arcane.json"), { force: true });
4834
+ await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
4835
+ await removeDirectoryIfEmpty(storeRoot);
4836
+ for (const line of describeFanoutOutcomes(fanout.outcomes)) console.log(` ${line}`);
4837
+ const pruned = fanout.outcomes.filter((o) => o.status === "pruned").length;
4838
+ const kept = fanout.outcomes.filter((o) => o.status === "kept-edited").length;
4839
+ console.log(
4840
+ `
4841
+ \u2713 Uninstalled the user tier \u2014 ${removed} spells and ${pruned} client files removed${kept > 0 ? `; ${kept} edited client file(s) kept` : ""}.`
4842
+ );
4843
+ }
4091
4844
 
4092
4845
  // src/commands/doctor.ts
4093
- import { access as access4, readFile as readFile9, mkdir as mkdir6, copyFile as fsCopyFile } from "fs/promises";
4094
- import { join as join12, dirname as dirname3 } from "path";
4846
+ import { access as access4, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
4847
+ import { join as join13, dirname as dirname8 } from "path";
4095
4848
  import { execFile as execFile3 } from "child_process";
4096
4849
  import { promisify as promisify2 } from "util";
4097
4850
 
@@ -4161,8 +4914,8 @@ function evaluateIncidentGate(records) {
4161
4914
  }
4162
4915
 
4163
4916
  // src/modules/denylist-scan.ts
4164
- import { readFile as readFile8, readdir as readdir5 } from "fs/promises";
4165
- import { join as join11, relative } from "path";
4917
+ import { readFile as readFile9, readdir as readdir6 } from "fs/promises";
4918
+ import { join as join12, relative } from "path";
4166
4919
  function escapeRegExp(value) {
4167
4920
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4168
4921
  }
@@ -4179,7 +4932,7 @@ async function scanFile(absolutePath, displayPath, rules) {
4179
4932
  const findings = [];
4180
4933
  let content;
4181
4934
  try {
4182
- content = await readFile8(absolutePath, "utf8");
4935
+ content = await readFile9(absolutePath, "utf8");
4183
4936
  } catch {
4184
4937
  return findings;
4185
4938
  }
@@ -4208,12 +4961,12 @@ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT
4208
4961
  const collected = [];
4209
4962
  let entries;
4210
4963
  try {
4211
- entries = await readdir5(root, { withFileTypes: true });
4964
+ entries = await readdir6(root, { withFileTypes: true });
4212
4965
  } catch {
4213
4966
  return collected;
4214
4967
  }
4215
4968
  for (const entry of entries) {
4216
- const absolutePath = join11(root, entry.name);
4969
+ const absolutePath = join12(root, entry.name);
4217
4970
  if (entry.isDirectory()) {
4218
4971
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
4219
4972
  collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
@@ -4425,13 +5178,13 @@ async function checkVSCodeExtension(id, label) {
4425
5178
  const name = `VS Code extension: ${label}`;
4426
5179
  const home = process.env["HOME"] ?? "";
4427
5180
  const extDirs = [
4428
- join12(home, ".vscode", "extensions"),
4429
- join12(home, ".vscode-insiders", "extensions")
5181
+ join13(home, ".vscode", "extensions"),
5182
+ join13(home, ".vscode-insiders", "extensions")
4430
5183
  ];
4431
5184
  for (const dir of extDirs) {
4432
5185
  try {
4433
- const { readdir: readdir8 } = await import("fs/promises");
4434
- const entries = await readdir8(dir);
5186
+ const { readdir: readdir9 } = await import("fs/promises");
5187
+ const entries = await readdir9(dir);
4435
5188
  const prefix = id.toLowerCase() + "-";
4436
5189
  const found = entries.some(
4437
5190
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -4460,13 +5213,13 @@ async function checkVSCodeExtension(id, label) {
4460
5213
  }
4461
5214
  async function checkArcaneManifest(targetDir) {
4462
5215
  const name = "Arcane manifest (.arcane.json)";
4463
- const manifestPath2 = join12(targetDir, ".arcane.json");
5216
+ const manifestPath2 = join13(targetDir, ".arcane.json");
4464
5217
  try {
4465
5218
  await access4(manifestPath2);
4466
5219
  } catch {
4467
- const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
5220
+ const selfHostedManifestPath = join13(targetDir, "src", "assets", ".arcane.json");
4468
5221
  try {
4469
- const raw = await readFile9(selfHostedManifestPath, "utf8");
5222
+ const raw = await readFile10(selfHostedManifestPath, "utf8");
4470
5223
  const manifest = JSON.parse(raw);
4471
5224
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
4472
5225
  return {
@@ -4485,7 +5238,7 @@ async function checkArcaneManifest(targetDir) {
4485
5238
  };
4486
5239
  }
4487
5240
  try {
4488
- const raw = await readFile9(manifestPath2, "utf8");
5241
+ const raw = await readFile10(manifestPath2, "utf8");
4489
5242
  const manifest = JSON.parse(raw);
4490
5243
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
4491
5244
  return {
@@ -4570,7 +5323,7 @@ async function checkSessionContinuity(targetDir) {
4570
5323
  const results = [];
4571
5324
  for (const file of SESSION_CONTINUITY_FILES) {
4572
5325
  const name = `Session file: ${file}`;
4573
- const filePath = join12(targetDir, file);
5326
+ const filePath = join13(targetDir, file);
4574
5327
  try {
4575
5328
  await access4(filePath);
4576
5329
  results.push({ name, passed: true, message: "present" });
@@ -4588,18 +5341,82 @@ async function checkSessionContinuity(targetDir) {
4588
5341
  async function fixSessionContinuity(targetDir, assetsDir) {
4589
5342
  const created = [];
4590
5343
  for (const file of SESSION_CONTINUITY_FILES) {
4591
- const destPath = join12(targetDir, file);
5344
+ const destPath = join13(targetDir, file);
4592
5345
  try {
4593
5346
  await access4(destPath);
4594
5347
  } catch {
4595
- const srcPath = join12(assetsDir, file);
4596
- await mkdir6(dirname3(destPath), { recursive: true });
5348
+ const srcPath = join13(assetsDir, file);
5349
+ await mkdir7(dirname8(destPath), { recursive: true });
4597
5350
  await fsCopyFile(srcPath, destPath);
4598
5351
  created.push(file);
4599
5352
  }
4600
5353
  }
4601
5354
  return created;
4602
5355
  }
5356
+ async function checkUserTier(homeDir = resolveHomeDir(), cliVersion) {
5357
+ const name = "User tier (ARC-045)";
5358
+ const storeRoot = userTierRoot(homeDir);
5359
+ let manifest;
5360
+ try {
5361
+ manifest = await readManifest(storeRoot);
5362
+ } catch (err) {
5363
+ if (err instanceof ManifestNotFoundError) {
5364
+ return {
5365
+ name,
5366
+ passed: true,
5367
+ blocking: false,
5368
+ message: "no user tier installed (optional \u2014 `spell init --user` shares every spell across the repositories on this machine)"
5369
+ };
5370
+ }
5371
+ return {
5372
+ name,
5373
+ passed: false,
5374
+ blocking: false,
5375
+ message: `could not read ${storeRoot}/.arcane.json (${err instanceof Error ? err.message : String(err)}) \u2014 remove it and re-run \`spell init --user\``
5376
+ };
5377
+ }
5378
+ try {
5379
+ if (typeof manifest.version !== "string" || manifest.version.length === 0) {
5380
+ return {
5381
+ name,
5382
+ passed: false,
5383
+ blocking: false,
5384
+ message: `${storeRoot}/.arcane.json has no version field \u2014 remove the store and re-run \`spell init --user\``
5385
+ };
5386
+ }
5387
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
5388
+ const problems = [];
5389
+ if (cliVersion !== void 0 && majorMinor(manifest.version) !== majorMinor(cliVersion)) {
5390
+ problems.push(`installed at v${manifest.version}, this CLI is v${cliVersion}`);
5391
+ }
5392
+ const health = await inspectUserTierFanout(homeDir, manifest.fanout);
5393
+ if (health.missing.length > 0) {
5394
+ problems.push(`${health.missing.length} of ${health.total} client file(s) missing`);
5395
+ }
5396
+ if (problems.length > 0) {
5397
+ return {
5398
+ name,
5399
+ passed: false,
5400
+ blocking: false,
5401
+ message: `${problems.join("; ")} \u2014 run \`spell update --user\` (regenerates missing files; never overwrites edited ones)`
5402
+ };
5403
+ }
5404
+ const customized = health.customized.length > 0 ? `, ${health.customized.length} customized` : "";
5405
+ return {
5406
+ name,
5407
+ passed: true,
5408
+ blocking: false,
5409
+ message: `v${manifest.version} at ${storeRoot} \u2014 ${health.total} client file(s) in place${customized}`
5410
+ };
5411
+ } catch (err) {
5412
+ return {
5413
+ name,
5414
+ passed: false,
5415
+ blocking: false,
5416
+ message: `could not inspect the user tier at ${storeRoot} (${err instanceof Error ? err.message : String(err)}) \u2014 run \`spell update --user\`; if that fails too, remove the store and re-run \`spell init --user\``
5417
+ };
5418
+ }
5419
+ }
4603
5420
  async function checkPushPolicy(targetDir) {
4604
5421
  const name = "Push policy (EF-09)";
4605
5422
  let manifest;
@@ -4653,10 +5470,10 @@ async function checkPushPolicy(targetDir) {
4653
5470
  }
4654
5471
  async function checkMcpConfig(targetDir) {
4655
5472
  const name = "MCP config (per-server timeout, I12)";
4656
- const filePath = join12(targetDir, ".mcp.json");
5473
+ const filePath = join13(targetDir, ".mcp.json");
4657
5474
  let raw;
4658
5475
  try {
4659
- raw = await readFile9(filePath, "utf8");
5476
+ raw = await readFile10(filePath, "utf8");
4660
5477
  } catch {
4661
5478
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
4662
5479
  }
@@ -4709,10 +5526,10 @@ function isDelegationsFile(value) {
4709
5526
  }
4710
5527
  async function checkDelegations(targetDir) {
4711
5528
  const name = "Standing delegations (T13)";
4712
- const filePath = join12(targetDir, ".arcane", "delegations.json");
5529
+ const filePath = join13(targetDir, ".arcane", "delegations.json");
4713
5530
  let raw;
4714
5531
  try {
4715
- raw = await readFile9(filePath, "utf8");
5532
+ raw = await readFile10(filePath, "utf8");
4716
5533
  } catch {
4717
5534
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
4718
5535
  }
@@ -4815,7 +5632,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4815
5632
  checkPushPolicy(targetDir),
4816
5633
  checkPlatformBranchPolicy(targetDir),
4817
5634
  checkDelegations(targetDir),
4818
- checkMcpConfig(targetDir)
5635
+ checkMcpConfig(targetDir),
5636
+ checkUserTier(resolveHomeDir(), options.packageVersion)
4819
5637
  ]);
4820
5638
  const sessionResults = await checkSessionContinuity(targetDir);
4821
5639
  results.push(...sessionResults);
@@ -4860,16 +5678,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4860
5678
  }
4861
5679
 
4862
5680
  // src/commands/ward.ts
4863
- import { readFile as readFile11 } from "fs/promises";
5681
+ import { readFile as readFile12 } from "fs/promises";
4864
5682
 
4865
5683
  // src/modules/ward.ts
4866
- import { readdir as readdir6, readFile as readFile10 } from "fs/promises";
4867
- import { join as join13, relative as relative2 } from "path";
5684
+ import { readdir as readdir7, readFile as readFile11 } from "fs/promises";
5685
+ import { join as join14, relative as relative2 } from "path";
4868
5686
  async function scanFileAllRules(absolutePath, displayPath, rules) {
4869
5687
  const findings = [];
4870
5688
  let content;
4871
5689
  try {
4872
- content = await readFile10(absolutePath, "utf8");
5690
+ content = await readFile11(absolutePath, "utf8");
4873
5691
  } catch {
4874
5692
  return findings;
4875
5693
  }
@@ -4922,12 +5740,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
4922
5740
  const findings = [];
4923
5741
  let entries;
4924
5742
  try {
4925
- entries = await readdir6(root, { withFileTypes: true });
5743
+ entries = await readdir7(root, { withFileTypes: true });
4926
5744
  } catch {
4927
5745
  return findings;
4928
5746
  }
4929
5747
  for (const entry of entries) {
4930
- const absolutePath = join13(root, entry.name);
5748
+ const absolutePath = join14(root, entry.name);
4931
5749
  if (entry.isDirectory()) {
4932
5750
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
4933
5751
  findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
@@ -4964,7 +5782,7 @@ async function runWard(repoRoot, options = {}) {
4964
5782
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
4965
5783
  if (findings2.length > 0) {
4966
5784
  try {
4967
- fileLines.set(displayPath, (await readFile10(absolutePath, "utf8")).split("\n"));
5785
+ fileLines.set(displayPath, (await readFile11(absolutePath, "utf8")).split("\n"));
4968
5786
  } catch {
4969
5787
  }
4970
5788
  }
@@ -4992,7 +5810,7 @@ function parseTermsFileContent(raw) {
4992
5810
  }
4993
5811
  async function readTermsFile(filePath) {
4994
5812
  try {
4995
- return parseTermsFileContent(await readFile11(filePath, "utf8"));
5813
+ return parseTermsFileContent(await readFile12(filePath, "utf8"));
4996
5814
  } catch (error) {
4997
5815
  console.warn(
4998
5816
  `\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
@@ -5042,7 +5860,7 @@ async function runWardCli(targetDir, options = {}, exclusions = []) {
5042
5860
  }
5043
5861
 
5044
5862
  // src/commands/unblock-push.ts
5045
- import { basename as basename2, resolve as resolve2 } from "path";
5863
+ import { basename as basename2, resolve as resolve3 } from "path";
5046
5864
  import { input as input4 } from "@inquirer/prompts";
5047
5865
  async function runUnblockPush(targetDir) {
5048
5866
  let manifest;
@@ -5074,7 +5892,7 @@ async function runUnblockPush(targetDir) {
5074
5892
  process.exit(1);
5075
5893
  return;
5076
5894
  }
5077
- const repoName = basename2(resolve2(targetDir));
5895
+ const repoName = basename2(resolve3(targetDir));
5078
5896
  if (repoName === "") {
5079
5897
  console.error(
5080
5898
  "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
@@ -5141,15 +5959,15 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
5141
5959
 
5142
5960
  // src/commands/report.ts
5143
5961
  import { access as access5 } from "fs/promises";
5144
- import { join as join16 } from "path";
5962
+ import { join as join17 } from "path";
5145
5963
 
5146
5964
  // src/modules/show-report/generate.ts
5147
- import { mkdir as mkdir7, readdir as readdir7, readFile as readFile13, writeFile as writeFile10 } from "fs/promises";
5148
- import { basename as basename3, dirname as dirname4, isAbsolute, join as join15, relative as relative3, resolve as resolve3 } from "path";
5965
+ import { mkdir as mkdir8, readdir as readdir8, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
5966
+ import { basename as basename3, dirname as dirname9, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
5149
5967
 
5150
5968
  // src/modules/show-report/model.ts
5151
- import { readFile as readFile12 } from "fs/promises";
5152
- import { join as join14 } from "path";
5969
+ import { readFile as readFile13 } from "fs/promises";
5970
+ import { join as join15 } from "path";
5153
5971
 
5154
5972
  // src/modules/show-report/plan-parser.ts
5155
5973
  function parseFrontmatter(content) {
@@ -5417,7 +6235,7 @@ async function getCast(cwd, fromRef, toRef) {
5417
6235
  // src/modules/show-report/model.ts
5418
6236
  async function readOptional(path4) {
5419
6237
  try {
5420
- return await readFile12(path4, "utf8");
6238
+ return await readFile13(path4, "utf8");
5421
6239
  } catch {
5422
6240
  return null;
5423
6241
  }
@@ -5513,13 +6331,13 @@ function buildCorrections(ledgerRows, programName) {
5513
6331
  };
5514
6332
  }
5515
6333
  async function buildShowReportModel(options) {
5516
- const planAbsPath = join14(options.rootDir, options.planRelPath);
5517
- const queueAbsPath = join14(options.rootDir, options.queueRelPath);
6334
+ const planAbsPath = join15(options.rootDir, options.planRelPath);
6335
+ const queueAbsPath = join15(options.rootDir, options.queueRelPath);
5518
6336
  const ledgerRelPath = "docs/verification-ledger.md";
5519
- const ledgerAbsPath = join14(options.rootDir, ledgerRelPath);
5520
- const decisionsAbsPath = join14(options.rootDir, "DECISIONS.md");
6337
+ const ledgerAbsPath = join15(options.rootDir, ledgerRelPath);
6338
+ const decisionsAbsPath = join15(options.rootDir, "DECISIONS.md");
5521
6339
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
5522
- readFile12(planAbsPath, "utf8"),
6340
+ readFile13(planAbsPath, "utf8"),
5523
6341
  readOptional(queueAbsPath),
5524
6342
  readOptional(ledgerAbsPath),
5525
6343
  readOptional(decisionsAbsPath)
@@ -5682,7 +6500,7 @@ function toPosix(path4) {
5682
6500
  async function programFromPlan(rootDir, slug, planRelPath) {
5683
6501
  let planContent;
5684
6502
  try {
5685
- planContent = await readFile13(join15(rootDir, planRelPath), "utf8");
6503
+ planContent = await readFile14(join16(rootDir, planRelPath), "utf8");
5686
6504
  } catch {
5687
6505
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
5688
6506
  }
@@ -5694,16 +6512,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
5694
6512
  program: {
5695
6513
  slug,
5696
6514
  planRelPath,
5697
- queueRelPath: toPosix(join15(dirname4(planRelPath), "OPERATOR-QUEUE.md")),
6515
+ queueRelPath: toPosix(join16(dirname9(planRelPath), "OPERATOR-QUEUE.md")),
5698
6516
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
5699
6517
  }
5700
6518
  };
5701
6519
  }
5702
6520
  async function discoverPrograms(rootDir) {
5703
- const plansDir = join15(rootDir, "docs", "plans");
6521
+ const plansDir = join16(rootDir, "docs", "plans");
5704
6522
  let entries;
5705
6523
  try {
5706
- entries = (await readdir7(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6524
+ entries = (await readdir8(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
5707
6525
  } catch (error) {
5708
6526
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
5709
6527
  throw error;
@@ -5718,23 +6536,23 @@ async function discoverPrograms(rootDir) {
5718
6536
  return { programs, skipped };
5719
6537
  }
5720
6538
  async function programFromPlanPath(rootDir, planPath) {
5721
- const planAbs = isAbsolute(planPath) ? planPath : resolve3(rootDir, planPath);
6539
+ const planAbs = isAbsolute(planPath) ? planPath : resolve4(rootDir, planPath);
5722
6540
  const planRelPath = toPosix(relative3(rootDir, planAbs));
5723
- return programFromPlan(rootDir, basename3(dirname4(planAbs)), planRelPath);
6541
+ return programFromPlan(rootDir, basename3(dirname9(planAbs)), planRelPath);
5724
6542
  }
5725
6543
  function normalizeLineEndings(content) {
5726
6544
  return content.replace(/\r\n?/g, "\n");
5727
6545
  }
5728
6546
  async function readOptionalFile(path4) {
5729
6547
  try {
5730
- return await readFile13(path4, "utf8");
6548
+ return await readFile14(path4, "utf8");
5731
6549
  } catch (error) {
5732
6550
  if (error.code === "ENOENT") return null;
5733
6551
  throw error;
5734
6552
  }
5735
6553
  }
5736
6554
  async function generateReport(rootDir, program2, templateHtml) {
5737
- const planContent = await readFile13(join15(rootDir, program2.planRelPath), "utf8");
6555
+ const planContent = await readFile14(join16(rootDir, program2.planRelPath), "utf8");
5738
6556
  const frontmatter = parseFrontmatter(planContent);
5739
6557
  const model = await buildShowReportModel({
5740
6558
  rootDir,
@@ -5752,7 +6570,7 @@ async function generateReport(rootDir, program2, templateHtml) {
5752
6570
  };
5753
6571
  }
5754
6572
  async function runReportGeneration(options) {
5755
- const templateHtml = await readFile13(options.templatePath, "utf8");
6573
+ const templateHtml = await readFile14(options.templatePath, "utf8");
5756
6574
  const drifted = [];
5757
6575
  const repaired = [];
5758
6576
  const unwritten = [];
@@ -5760,19 +6578,19 @@ async function runReportGeneration(options) {
5760
6578
  const generated = await generateReport(options.rootDir, program2, templateHtml);
5761
6579
  const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
5762
6580
  if (missing.length > 0) unwritten.push({ slug: program2.slug, ids: missing });
5763
- const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve3(options.rootDir, options.outDir) : resolve3(options.rootDir, dirname4(program2.planRelPath));
6581
+ const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname9(program2.planRelPath));
5764
6582
  const outRel = toPosix(relative3(options.rootDir, outAbs));
5765
6583
  const outputs = [
5766
6584
  [`${outRel}/show-report.json`, generated.json],
5767
6585
  [`${outRel}/show-report.html`, generated.html]
5768
6586
  ];
5769
6587
  for (const [relPath, expected] of outputs) {
5770
- const actual = await readOptionalFile(join15(options.rootDir, relPath));
6588
+ const actual = await readOptionalFile(join16(options.rootDir, relPath));
5771
6589
  if (actual === null || normalizeLineEndings(actual) !== normalizeLineEndings(expected)) {
5772
6590
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
5773
6591
  if (options.mode === "write") {
5774
- await mkdir7(outAbs, { recursive: true });
5775
- await writeFile10(join15(options.rootDir, relPath), expected, "utf8");
6592
+ await mkdir8(outAbs, { recursive: true });
6593
+ await writeFile11(join16(options.rootDir, relPath), expected, "utf8");
5776
6594
  repaired.push(relPath);
5777
6595
  }
5778
6596
  }
@@ -5783,7 +6601,7 @@ async function runReportGeneration(options) {
5783
6601
 
5784
6602
  // src/commands/report.ts
5785
6603
  async function runReport(targetDir, options = {}, assetsDir) {
5786
- const templatePath = join16(assetsDir, "report", "show-report.template.html");
6604
+ const templatePath = join17(assetsDir, "report", "show-report.template.html");
5787
6605
  try {
5788
6606
  await access5(templatePath);
5789
6607
  } catch {
@@ -5846,8 +6664,12 @@ async function runReport(targetDir, options = {}, assetsDir) {
5846
6664
  import { realpathSync } from "fs";
5847
6665
  var require3 = createRequire2(import.meta.url);
5848
6666
  var pkg = require3("../package.json");
5849
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join17(dirname5(fileURLToPath(import.meta.url)), "assets");
6667
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join18(dirname10(fileURLToPath(import.meta.url)), "assets");
5850
6668
  var program = new Command();
6669
+ 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)";
6670
+ function targetDirFor(opts) {
6671
+ return opts.user ? userTierRoot() : process.cwd();
6672
+ }
5851
6673
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
5852
6674
  if (program.args.length > 0) {
5853
6675
  const [attempted] = program.args;
@@ -5867,15 +6689,16 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
5867
6689
  await printWelcome(pkg.version, process.cwd());
5868
6690
  program.outputHelp();
5869
6691
  });
5870
- program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").action(
6692
+ program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").option("--user", USER_FLAG_DESCRIPTION).action(
5871
6693
  async (opts) => {
5872
6694
  await runInit(
5873
6695
  {
5874
6696
  profile: opts.profile,
5875
6697
  force: opts.force,
5876
- dryRun: opts.dryRun
6698
+ dryRun: opts.dryRun,
6699
+ user: opts.user
5877
6700
  },
5878
- process.cwd(),
6701
+ targetDirFor(opts),
5879
6702
  ASSETS_DIR,
5880
6703
  pkg.version
5881
6704
  );
@@ -5895,25 +6718,29 @@ program.command("add <component>").description("Add a component to an existing A
5895
6718
  program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
5896
6719
  "--prune",
5897
6720
  "Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
5898
- ).action(async (opts) => {
6721
+ ).option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
5899
6722
  await runUpdate(
5900
- { dryRun: opts.dryRun, prune: opts.prune },
5901
- process.cwd(),
6723
+ { dryRun: opts.dryRun, prune: opts.prune, user: opts.user },
6724
+ targetDirFor(opts),
5902
6725
  ASSETS_DIR,
5903
6726
  pkg.version
5904
6727
  );
5905
6728
  });
5906
- program.command("status").description("Show installed components and check for updates").action(async () => {
5907
- await runStatus(process.cwd(), pkg.version);
6729
+ program.command("status").description("Show installed components and check for updates").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
6730
+ await runStatus(targetDirFor(opts), pkg.version, { user: opts.user });
5908
6731
  });
5909
- program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").action(async (opts) => {
5910
- await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
6732
+ program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
6733
+ await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
5911
6734
  });
5912
6735
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
5913
6736
  await runUnblockPush(process.cwd());
5914
6737
  });
5915
6738
  program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
5916
- await runDoctor(process.cwd(), { fix: opts.fix, leaks: opts.leaks }, ASSETS_DIR);
6739
+ await runDoctor(
6740
+ process.cwd(),
6741
+ { fix: opts.fix, leaks: opts.leaks, packageVersion: pkg.version },
6742
+ ASSETS_DIR
6743
+ );
5917
6744
  });
5918
6745
  program.command("ward").description("Scan the repo for leaked identifiers -- third-party trademarks and the operator's own personal identifiers alike (T18)").option(
5919
6746
  "--terms <terms>",